Notes page revamp! (#487)

* Setup wide layout

* fixup! Setup wide layout

* Setup new layout
This commit is contained in:
Chris 2025-11-06 17:59:30 +02:00 committed by GitHub
parent 92f12aad77
commit 72d0baeb75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1299 additions and 412 deletions

View file

@ -4,5 +4,5 @@ module.exports = {
'config': path.resolve('backend', 'config', 'database.js'),
'models-path': path.resolve('backend', 'models'),
'seeders-path': path.resolve('backend', 'seeders'),
'migrations-path': path.resolve('migrations')
'migrations-path': path.resolve('backend', 'migrations')
};

View 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');
},
};

View file

@ -40,6 +40,10 @@ module.exports = (sequelize) => {
key: 'id',
},
},
color: {
type: DataTypes.STRING,
allowNull: true,
},
},
{
tableName: 'notes',

View file

@ -142,7 +142,8 @@ router.get(
// POST /api/note
router.post('/note', async (req, res) => {
try {
const { title, content, project_uid, project_id, tags } = req.body;
const { title, content, project_uid, project_id, tags, color } =
req.body;
const noteAttributes = {
title,
@ -150,6 +151,11 @@ router.post('/note', async (req, res) => {
user_id: req.session.userId,
};
// Add color if provided
if (color !== undefined) {
noteAttributes.color = color;
}
// Support both project_uid (new) and project_id (legacy)
const projectIdentifier = project_uid || project_id;
@ -262,11 +268,13 @@ router.patch(
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 = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
if (color !== undefined) updateData.color = color;
// Handle project assignment - support both project_uid (new) and project_id (legacy)
const projectIdentifier =

View file

@ -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();
});

View file

@ -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();
});

View file

@ -238,6 +238,7 @@ const App: React.FC = () => {
element={<ViewDetail />}
/>
<Route path="/notes" element={<Notes />} />
<Route path="/notes/:uid" element={<Notes />} />
<Route
path="/note/:uidSlug"
element={<NoteDetails />}

View file

@ -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-grow py-6 px-2 md:px-6 transition-all duration-300 ${
isMobileSearchOpen ? 'pt-32' : 'pt-24'
} md:pt-24`}
className={`flex-grow py-0 px-0 md:px-4 transition-all duration-300 ${
isMobileSearchOpen ? 'pt-32' : 'pt-20'
} md:pt-20`}
>
<div className="w-full">{children}</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ interface MarkdownRendererProps {
className?: string;
summaryMode?: boolean;
onContentChange?: (newContent: string) => void;
noteColor?: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
@ -16,7 +17,22 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
className = '',
summaryMode = false,
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(() => {
// Configure highlight.js
hljs.configure({
@ -92,72 +108,108 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
h1: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
h2: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
h3: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
h4: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
h5: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
h6: ({ ...props }) =>
summaryMode ? (
<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}
/>
) : (
<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}
/>
),
@ -165,7 +217,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Customize paragraph styles
p: ({ ...props }) => (
<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}
/>
),
@ -173,17 +228,35 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Customize list styles
ul: ({ ...props }) => (
<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}
/>
),
ol: ({ ...props }) => (
<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}
/>
),
li: ({ ...props }) => <li className="ml-4" {...props} />,
// Customize link styles
a: ({ ...props }) => (
@ -225,7 +298,7 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}
return (
<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}
>
{children}
@ -293,7 +366,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Customize strong/bold text
strong: ({ ...props }) => (
<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}
/>
),
@ -301,7 +377,10 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// Customize italic text
em: ({ ...props }) => (
<em
className="italic text-gray-700 dark:text-gray-300"
className={`italic ${!noteColor ? 'text-gray-700 dark:text-gray-300' : ''}`}
style={{
color: textColor,
}}
{...props}
/>
),

View 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,
};

View file

@ -9,6 +9,7 @@ export interface Note {
updated_at?: string;
project_id?: number; // Foreign key for project (deprecated, use project_uid)
project_uid?: string; // Foreign key for project by uid
color?: string; // Background color for the note
tags?: Tag[];
Tags?: Tag[]; // Sequelize association naming (capitalized)
project?: {

View file

@ -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 { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
@ -16,6 +9,29 @@ import './styles/markdown.css'; // Import markdown styles
import { I18nextProvider } from 'react-i18next';
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 prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
@ -32,11 +48,8 @@ if (isDarkMode) {
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) {
root = createRoot(container);
const root = createRoot(container);
root.render(
<I18nextProvider i18n={i18n}>
<BrowserRouter>
@ -49,24 +62,3 @@ if (container) {
</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>
);
}
});
}

View file

@ -1,8 +1,8 @@
const path = require('path');
const webpack = require('webpack');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
const isDevelopment = process.env.NODE_ENV !== 'production';
@ -63,6 +63,16 @@ module.exports = {
plugins: [
isDevelopment && new ReactRefreshWebpackPlugin(),
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({
title: 'tududi',
filename: 'index.html',