Notes page revamp! (#487)
* Setup wide layout * fixup! Setup wide layout * Setup new layout
This commit is contained in:
parent
92f12aad77
commit
72d0baeb75
14 changed files with 1299 additions and 412 deletions
|
|
@ -4,5 +4,5 @@ module.exports = {
|
||||||
'config': path.resolve('backend', 'config', 'database.js'),
|
'config': path.resolve('backend', 'config', 'database.js'),
|
||||||
'models-path': path.resolve('backend', 'models'),
|
'models-path': path.resolve('backend', 'models'),
|
||||||
'seeders-path': path.resolve('backend', 'seeders'),
|
'seeders-path': path.resolve('backend', 'seeders'),
|
||||||
'migrations-path': path.resolve('migrations')
|
'migrations-path': path.resolve('backend', 'migrations')
|
||||||
};
|
};
|
||||||
|
|
|
||||||
24
backend/migrations/20251106000001-add-color-to-notes.js
Normal file
24
backend/migrations/20251106000001-add-color-to-notes.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
safeAddColumns,
|
||||||
|
safeRemoveColumn,
|
||||||
|
} = require('../utils/migration-utils');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await safeAddColumns(queryInterface, 'notes', [
|
||||||
|
{
|
||||||
|
name: 'color',
|
||||||
|
definition: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await safeRemoveColumn(queryInterface, 'notes', 'color');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -40,6 +40,10 @@ module.exports = (sequelize) => {
|
||||||
key: 'id',
|
key: 'id',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
color: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: 'notes',
|
tableName: 'notes',
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@ router.get(
|
||||||
// POST /api/note
|
// POST /api/note
|
||||||
router.post('/note', async (req, res) => {
|
router.post('/note', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, content, project_uid, project_id, tags } = req.body;
|
const { title, content, project_uid, project_id, tags, color } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
const noteAttributes = {
|
const noteAttributes = {
|
||||||
title,
|
title,
|
||||||
|
|
@ -150,6 +151,11 @@ router.post('/note', async (req, res) => {
|
||||||
user_id: req.session.userId,
|
user_id: req.session.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add color if provided
|
||||||
|
if (color !== undefined) {
|
||||||
|
noteAttributes.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
// Support both project_uid (new) and project_id (legacy)
|
// Support both project_uid (new) and project_id (legacy)
|
||||||
const projectIdentifier = project_uid || project_id;
|
const projectIdentifier = project_uid || project_id;
|
||||||
|
|
||||||
|
|
@ -262,11 +268,13 @@ router.patch(
|
||||||
where: { uid: req.params.uid },
|
where: { uid: req.params.uid },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { title, content, project_uid, project_id, tags } = req.body;
|
const { title, content, project_uid, project_id, tags, color } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = title;
|
||||||
if (content !== undefined) updateData.content = content;
|
if (content !== undefined) updateData.content = content;
|
||||||
|
if (color !== undefined) updateData.color = color;
|
||||||
|
|
||||||
// Handle project assignment - support both project_uid (new) and project_id (legacy)
|
// Handle project assignment - support both project_uid (new) and project_id (legacy)
|
||||||
const projectIdentifier =
|
const projectIdentifier =
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
login,
|
|
||||||
navigateAndWait,
|
|
||||||
clickAndWaitForModal,
|
|
||||||
waitForElement,
|
|
||||||
createUniqueEntity,
|
|
||||||
waitForNetworkIdle
|
|
||||||
} from '../helpers/testHelpers';
|
|
||||||
|
|
||||||
// Navigate to notes page after login
|
|
||||||
async function loginAndNavigateToNotes(page, baseURL) {
|
|
||||||
const appUrl = await login(page, baseURL);
|
|
||||||
await navigateAndWait(page, appUrl + '/notes');
|
|
||||||
await expect(page).toHaveURL(/\/notes/);
|
|
||||||
return appUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a note with checkbox content
|
|
||||||
async function createNoteWithCheckboxes(page, noteTitle) {
|
|
||||||
const addNoteButton = page.locator('[data-testid="add-note-button"]');
|
|
||||||
const titleInput = page.locator('[data-testid="note-title-input"]');
|
|
||||||
|
|
||||||
await clickAndWaitForModal(addNoteButton, titleInput);
|
|
||||||
|
|
||||||
// Fill in the note title
|
|
||||||
await titleInput.click();
|
|
||||||
await titleInput.clear();
|
|
||||||
await titleInput.type(noteTitle, { delay: 50 });
|
|
||||||
|
|
||||||
// Fill in note content with checkboxes
|
|
||||||
const contentTextarea = page.locator('[data-testid="note-content-textarea"]');
|
|
||||||
await contentTextarea.click();
|
|
||||||
await contentTextarea.fill('# Shopping List\n\n- [ ] Buy milk\n- [ ] Buy eggs\n- [ ] Buy bread');
|
|
||||||
|
|
||||||
// Save the note
|
|
||||||
await page.locator('[data-testid="note-save-button"]').click();
|
|
||||||
|
|
||||||
// Wait for the modal to close
|
|
||||||
await waitForElement(titleInput, { state: 'hidden' });
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('user can toggle checkboxes in note detail view and changes are saved', async ({ page, baseURL }) => {
|
|
||||||
await loginAndNavigateToNotes(page, baseURL);
|
|
||||||
|
|
||||||
// Create a note with checkboxes
|
|
||||||
const noteTitle = createUniqueEntity('Shopping List');
|
|
||||||
await createNoteWithCheckboxes(page, noteTitle);
|
|
||||||
|
|
||||||
// Click on the note to view it
|
|
||||||
const noteLink = page.locator('a').filter({ hasText: noteTitle });
|
|
||||||
await expect(noteLink).toBeVisible();
|
|
||||||
await noteLink.click();
|
|
||||||
|
|
||||||
// Wait for note detail page to load
|
|
||||||
await expect(page).toHaveURL(/\/note\//);
|
|
||||||
|
|
||||||
// Wait for checkboxes to be visible
|
|
||||||
const checkboxes = page.locator('input[type="checkbox"]');
|
|
||||||
await expect(checkboxes.first()).toBeVisible();
|
|
||||||
|
|
||||||
// Verify we have 3 checkboxes
|
|
||||||
await expect(checkboxes).toHaveCount(3);
|
|
||||||
|
|
||||||
// Verify all checkboxes are initially unchecked
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
await expect(checkboxes.nth(i)).not.toBeChecked();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click the first checkbox (Buy milk)
|
|
||||||
await checkboxes.first().click();
|
|
||||||
|
|
||||||
// Wait for the save to complete
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Verify the checkbox is now checked
|
|
||||||
await expect(checkboxes.first()).toBeChecked();
|
|
||||||
|
|
||||||
// Refresh the page to verify the change was saved
|
|
||||||
await page.reload();
|
|
||||||
await expect(checkboxes.first()).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the first checkbox is still checked after reload
|
|
||||||
await expect(checkboxes.first()).toBeChecked();
|
|
||||||
|
|
||||||
// Verify other checkboxes are still unchecked
|
|
||||||
await expect(checkboxes.nth(1)).not.toBeChecked();
|
|
||||||
await expect(checkboxes.nth(2)).not.toBeChecked();
|
|
||||||
|
|
||||||
// Toggle the first checkbox back to unchecked
|
|
||||||
await checkboxes.first().click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Verify it's now unchecked
|
|
||||||
await expect(checkboxes.first()).not.toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
login,
|
|
||||||
navigateAndWait,
|
|
||||||
clickAndWaitForModal,
|
|
||||||
fillInputReliably,
|
|
||||||
waitForElement,
|
|
||||||
hoverAndWaitForVisible,
|
|
||||||
confirmDialog,
|
|
||||||
createUniqueEntity,
|
|
||||||
waitForNetworkIdle
|
|
||||||
} from '../helpers/testHelpers';
|
|
||||||
|
|
||||||
// Navigate to notes page after login
|
|
||||||
async function loginAndNavigateToNotes(page, baseURL) {
|
|
||||||
const appUrl = await login(page, baseURL);
|
|
||||||
|
|
||||||
// Navigate to notes page
|
|
||||||
await navigateAndWait(page, appUrl + '/notes');
|
|
||||||
await expect(page).toHaveURL(/\/notes/);
|
|
||||||
|
|
||||||
return appUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared function to create a note via the sidebar button
|
|
||||||
async function createNote(page, noteTitle, noteContent = '') {
|
|
||||||
// Find and click the "Add Note" button in the sidebar
|
|
||||||
const addNoteButton = page.locator('[data-testid="add-note-button"]');
|
|
||||||
const titleInput = page.locator('[data-testid="note-title-input"]');
|
|
||||||
|
|
||||||
await clickAndWaitForModal(addNoteButton, titleInput);
|
|
||||||
|
|
||||||
// Fill in the note title
|
|
||||||
await titleInput.click();
|
|
||||||
await titleInput.clear();
|
|
||||||
await titleInput.type(noteTitle, { delay: 50 });
|
|
||||||
|
|
||||||
// Fill in the note content if provided
|
|
||||||
if (noteContent) {
|
|
||||||
const contentTextarea = page.locator('[data-testid="note-content-textarea"]');
|
|
||||||
await contentTextarea.click();
|
|
||||||
await contentTextarea.fill(noteContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the note using the specific test ID
|
|
||||||
await page.locator('[data-testid="note-save-button"]').click();
|
|
||||||
|
|
||||||
// Wait for the modal to close
|
|
||||||
await waitForElement(titleInput, { state: 'hidden' });
|
|
||||||
|
|
||||||
// Wait for note creation to complete
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('user can create a new note and verify it appears in the notes list', async ({ page, baseURL }) => {
|
|
||||||
await loginAndNavigateToNotes(page, baseURL);
|
|
||||||
|
|
||||||
// Create a unique test note
|
|
||||||
const noteTitle = createUniqueEntity('Test Note');
|
|
||||||
const noteContent = 'This is test content for note';
|
|
||||||
await createNote(page, noteTitle, noteContent);
|
|
||||||
|
|
||||||
// Verify the note appears in the notes list
|
|
||||||
await expect(page.getByText(noteTitle)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user can update an existing note', async ({ page, baseURL }) => {
|
|
||||||
await loginAndNavigateToNotes(page, baseURL);
|
|
||||||
|
|
||||||
// Create an initial note
|
|
||||||
const originalNoteTitle = createUniqueEntity('Test note to edit');
|
|
||||||
const originalNoteContent = 'Original content';
|
|
||||||
await createNote(page, originalNoteTitle, originalNoteContent);
|
|
||||||
|
|
||||||
// Find the specific note card by title text
|
|
||||||
const noteCard = page.locator('a').filter({ hasText: originalNoteTitle });
|
|
||||||
await expect(noteCard).toBeVisible();
|
|
||||||
|
|
||||||
// Hover over the note card and wait for dropdown button to be visible
|
|
||||||
const dropdownButton = noteCard.locator('..').locator('button[data-testid^="note-dropdown-"]');
|
|
||||||
await hoverAndWaitForVisible(noteCard, dropdownButton);
|
|
||||||
|
|
||||||
// Click dropdown button
|
|
||||||
await dropdownButton.click();
|
|
||||||
|
|
||||||
// Wait for dropdown menu to appear and click Edit
|
|
||||||
const editButton = page.locator('button[data-testid^="note-edit-"]').first();
|
|
||||||
await waitForElement(editButton);
|
|
||||||
await editButton.click();
|
|
||||||
|
|
||||||
// Wait for the Note Modal to appear with the note data
|
|
||||||
const noteTitleInput = page.locator('[data-testid="note-title-input"]');
|
|
||||||
await waitForElement(noteTitleInput);
|
|
||||||
|
|
||||||
// Verify the note title field is pre-filled
|
|
||||||
await expect(noteTitleInput).toHaveValue(originalNoteTitle);
|
|
||||||
|
|
||||||
// Edit the note title and content
|
|
||||||
const editedNoteTitle = createUniqueEntity('Edited test note');
|
|
||||||
const editedNoteContent = 'Edited content';
|
|
||||||
await fillInputReliably(noteTitleInput, editedNoteTitle);
|
|
||||||
|
|
||||||
const noteContentTextarea = page.locator('[data-testid="note-content-textarea"]');
|
|
||||||
await noteContentTextarea.clear();
|
|
||||||
await noteContentTextarea.fill(editedNoteContent);
|
|
||||||
|
|
||||||
// Save the changes
|
|
||||||
await page.locator('[data-testid="note-save-button"]').click();
|
|
||||||
|
|
||||||
// Wait for the modal to close
|
|
||||||
await waitForElement(noteTitleInput, { state: 'hidden' });
|
|
||||||
|
|
||||||
// Verify the edited note appears in the notes list
|
|
||||||
await expect(page.getByText(editedNoteTitle)).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the original note title is no longer visible
|
|
||||||
await expect(page.getByText(originalNoteTitle)).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user can delete an existing note', async ({ page, baseURL }) => {
|
|
||||||
await loginAndNavigateToNotes(page, baseURL);
|
|
||||||
|
|
||||||
// Create an initial note
|
|
||||||
const noteTitle = createUniqueEntity('Test note to delete');
|
|
||||||
const noteContent = 'Content to delete';
|
|
||||||
await createNote(page, noteTitle, noteContent);
|
|
||||||
|
|
||||||
// Find the specific note card by title text
|
|
||||||
const noteCard = page.locator('a').filter({ hasText: noteTitle });
|
|
||||||
await expect(noteCard).toBeVisible();
|
|
||||||
|
|
||||||
// Hover over the note card and wait for dropdown button to be visible
|
|
||||||
const dropdownButton = noteCard.locator('..').locator('button[data-testid^="note-dropdown-"]');
|
|
||||||
await hoverAndWaitForVisible(noteCard, dropdownButton);
|
|
||||||
|
|
||||||
// Click dropdown button
|
|
||||||
await dropdownButton.click();
|
|
||||||
|
|
||||||
// Wait for dropdown menu to appear and click Delete
|
|
||||||
const deleteButton = page.locator('button[data-testid^="note-delete-"]').first();
|
|
||||||
await waitForElement(deleteButton);
|
|
||||||
await deleteButton.click();
|
|
||||||
|
|
||||||
// Wait for and handle the confirmation dialog
|
|
||||||
await confirmDialog(page, 'Delete Note');
|
|
||||||
|
|
||||||
// Verify the note is no longer visible in the notes list
|
|
||||||
await expect(page.getByRole('link', { name: new RegExp(noteTitle) })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
@ -238,6 +238,7 @@ const App: React.FC = () => {
|
||||||
element={<ViewDetail />}
|
element={<ViewDetail />}
|
||||||
/>
|
/>
|
||||||
<Route path="/notes" element={<Notes />} />
|
<Route path="/notes" element={<Notes />} />
|
||||||
|
<Route path="/notes/:uid" element={<Notes />} />
|
||||||
<Route
|
<Route
|
||||||
path="/note/:uidSlug"
|
path="/note/:uidSlug"
|
||||||
element={<NoteDetails />}
|
element={<NoteDetails />}
|
||||||
|
|
|
||||||
|
|
@ -459,9 +459,9 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
>
|
>
|
||||||
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
|
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className={`flex-grow py-6 px-2 md:px-6 transition-all duration-300 ${
|
className={`flex-grow py-0 px-0 md:px-4 transition-all duration-300 ${
|
||||||
isMobileSearchOpen ? 'pt-32' : 'pt-24'
|
isMobileSearchOpen ? 'pt-32' : 'pt-20'
|
||||||
} md:pt-24`}
|
} md:pt-20`}
|
||||||
>
|
>
|
||||||
<div className="w-full">{children}</div>
|
<div className="w-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ interface MarkdownRendererProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
summaryMode?: boolean;
|
summaryMode?: boolean;
|
||||||
onContentChange?: (newContent: string) => void;
|
onContentChange?: (newContent: string) => void;
|
||||||
|
noteColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
|
|
@ -16,7 +17,22 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
summaryMode = false,
|
summaryMode = false,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
|
noteColor,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Determine text color based on background
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (!noteColor) return undefined;
|
||||||
|
|
||||||
|
const hex = noteColor.replace('#', '');
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
|
||||||
|
return luminance < 0.4 ? '#ffffff' : '#333333';
|
||||||
|
};
|
||||||
|
|
||||||
|
const textColor = getTextColor();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Configure highlight.js
|
// Configure highlight.js
|
||||||
hljs.configure({
|
hljs.configure({
|
||||||
|
|
@ -92,72 +108,108 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
h1: ({ ...props }) =>
|
h1: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100"
|
className={`text-3xl font-bold mb-4 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h2: ({ ...props }) =>
|
h2: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h2
|
<h2
|
||||||
className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100"
|
className={`text-2xl font-semibold mb-3 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h3: ({ ...props }) =>
|
h3: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h3
|
<h3
|
||||||
className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100"
|
className={`text-xl font-medium mb-2 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h4: ({ ...props }) =>
|
h4: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h4
|
<h4
|
||||||
className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100"
|
className={`text-lg font-medium mb-2 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h5: ({ ...props }) =>
|
h5: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h5
|
<h5
|
||||||
className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100"
|
className={`text-base font-medium mb-2 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h6: ({ ...props }) =>
|
h6: ({ ...props }) =>
|
||||||
summaryMode ? (
|
summaryMode ? (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h6
|
<h6
|
||||||
className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100"
|
className={`text-sm font-medium mb-2 ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
@ -165,7 +217,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
// Customize paragraph styles
|
// Customize paragraph styles
|
||||||
p: ({ ...props }) => (
|
p: ({ ...props }) => (
|
||||||
<p
|
<p
|
||||||
className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed"
|
className={`mb-3 leading-relaxed ${!noteColor ? 'text-gray-700 dark:text-gray-300' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
@ -173,17 +228,35 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
// Customize list styles
|
// Customize list styles
|
||||||
ul: ({ ...props }) => (
|
ul: ({ ...props }) => (
|
||||||
<ul
|
<ul
|
||||||
className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300"
|
className={`mb-3 list-disc ml-4 pl-5 space-y-1 ${!noteColor ? 'text-gray-700 dark:text-gray-300' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ol: ({ ...props }) => (
|
ol: ({ ...props }) => (
|
||||||
<ol
|
<ol
|
||||||
className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300"
|
className={`mb-3 list-decimal ml-4 pl-5 space-y-1 ${!noteColor ? 'text-gray-700 dark:text-gray-300' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
li: ({ ...props }) => (
|
||||||
|
<li
|
||||||
|
className={
|
||||||
|
!noteColor
|
||||||
|
? 'text-gray-700 dark:text-gray-300'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
li: ({ ...props }) => <li className="ml-4" {...props} />,
|
|
||||||
|
|
||||||
// Customize link styles
|
// Customize link styles
|
||||||
a: ({ ...props }) => (
|
a: ({ ...props }) => (
|
||||||
|
|
@ -225,7 +298,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100"
|
className="py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -293,7 +366,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
// Customize strong/bold text
|
// Customize strong/bold text
|
||||||
strong: ({ ...props }) => (
|
strong: ({ ...props }) => (
|
||||||
<strong
|
<strong
|
||||||
className="font-semibold text-gray-900 dark:text-gray-100"
|
className={`font-semibold ${!noteColor ? 'text-gray-900 dark:text-gray-100' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
@ -301,7 +377,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
// Customize italic text
|
// Customize italic text
|
||||||
em: ({ ...props }) => (
|
em: ({ ...props }) => (
|
||||||
<em
|
<em
|
||||||
className="italic text-gray-700 dark:text-gray-300"
|
className={`italic ${!noteColor ? 'text-gray-700 dark:text-gray-300' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
21
frontend/config/featureFlags.ts
Normal file
21
frontend/config/featureFlags.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
const parseBooleanFlag = (
|
||||||
|
value: string | undefined,
|
||||||
|
defaultValue: boolean
|
||||||
|
): boolean => {
|
||||||
|
if (value === undefined) return defaultValue;
|
||||||
|
const normalized = value.toString().toLowerCase();
|
||||||
|
return !['false', '0', 'off', 'no'].includes(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENABLE_NOTE_COLOR = parseBooleanFlag(
|
||||||
|
process.env.ENABLE_NOTE_COLOR,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
export type FeatureFlags = {
|
||||||
|
ENABLE_NOTE_COLOR: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const featureFlags: FeatureFlags = {
|
||||||
|
ENABLE_NOTE_COLOR,
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ export interface Note {
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
project_id?: number; // Foreign key for project (deprecated, use project_uid)
|
project_id?: number; // Foreign key for project (deprecated, use project_uid)
|
||||||
project_uid?: string; // Foreign key for project by uid
|
project_uid?: string; // Foreign key for project by uid
|
||||||
|
color?: string; // Background color for the note
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
Tags?: Tag[]; // Sequelize association naming (capitalized)
|
Tags?: Tag[]; // Sequelize association naming (capitalized)
|
||||||
project?: {
|
project?: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
// Add type declaration for module.hot
|
|
||||||
declare const module: {
|
|
||||||
hot?: {
|
|
||||||
accept: (path: string, callback: () => void) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
@ -16,6 +9,29 @@ import './styles/markdown.css'; // Import markdown styles
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from './i18n'; // Import the i18n instance with its configuration
|
import i18n from './i18n'; // Import the i18n instance with its configuration
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
// Clear out any lingering service workers/caches from other branches (e.g. PWA)
|
||||||
|
if (isDevelopment && 'serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||||
|
registrations.forEach((registration) => {
|
||||||
|
registration.unregister().catch(() => {
|
||||||
|
// Non-fatal during development cleanup
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('caches' in window) {
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
cacheNames.forEach((cacheName) => {
|
||||||
|
caches.delete(cacheName).catch(() => {
|
||||||
|
// Ignore cache cleanup failures during dev
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const storedPreference = localStorage.getItem('isDarkMode');
|
const storedPreference = localStorage.getItem('isDarkMode');
|
||||||
const prefersDarkMode = window.matchMedia(
|
const prefersDarkMode = window.matchMedia(
|
||||||
'(prefers-color-scheme: dark)'
|
'(prefers-color-scheme: dark)'
|
||||||
|
|
@ -32,11 +48,8 @@ if (isDarkMode) {
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
|
|
||||||
// Store the root outside the if block so it can be accessed by the HMR code
|
|
||||||
let root: any;
|
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -49,24 +62,3 @@ if (container) {
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
|
|
||||||
// Learn more: https://www.webpackjs.com/concepts/hot-module-replacement/
|
|
||||||
if (module.hot) {
|
|
||||||
module.hot.accept('./App', () => {
|
|
||||||
// New version of App component imported
|
|
||||||
if (root) {
|
|
||||||
root.render(
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<BrowserRouter>
|
|
||||||
<ToastProvider>
|
|
||||||
<TelegramStatusProvider>
|
|
||||||
<App />
|
|
||||||
</TelegramStatusProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</I18nextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const webpack = require('webpack');
|
|
||||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
|
@ -63,6 +63,16 @@ module.exports = {
|
||||||
plugins: [
|
plugins: [
|
||||||
isDevelopment && new ReactRefreshWebpackPlugin(),
|
isDevelopment && new ReactRefreshWebpackPlugin(),
|
||||||
isDevelopment && new webpack.HotModuleReplacementPlugin(),
|
isDevelopment && new webpack.HotModuleReplacementPlugin(),
|
||||||
|
new webpack.DefinePlugin(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries({
|
||||||
|
ENABLE_NOTE_COLOR: process.env.ENABLE_NOTE_COLOR,
|
||||||
|
}).map(([key, value]) => [
|
||||||
|
`process.env.${key}`,
|
||||||
|
JSON.stringify(value),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
title: 'tududi',
|
title: 'tududi',
|
||||||
filename: 'index.html',
|
filename: 'index.html',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue