diff --git a/web/package-lock.json b/web/package-lock.json index 0db28fe..fa95f94 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,9 @@ "name": "qnote", "version": "0.0.1", "dependencies": { - "marked": "^15.0.7" + "highlight.js": "^11.11.1", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1" }, "devDependencies": { "@sveltejs/adapter-auto": "^4.0.0", @@ -1552,6 +1554,15 @@ "dev": true, "license": "ISC" }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/import-meta-resolve": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", @@ -1882,6 +1893,15 @@ "node": ">= 18" } }, + "node_modules/marked-highlight": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.1.tgz", + "integrity": "sha512-SiCIeEiQbs9TxGwle9/OwbOejHCZsohQRaNTY2u8euEXYt2rYUFoiImUirThU3Gd/o6Q1gHGtH9qloHlbJpNIA==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <16" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", diff --git a/web/package.json b/web/package.json index f343cde..6c5c9d1 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,8 @@ "vite": "^6.0.0" }, "dependencies": { - "marked": "^15.0.7" + "highlight.js": "^11.11.1", + "marked": "^15.0.7", + "marked-highlight": "^2.2.1" } } diff --git a/web/src/app.css b/web/src/app.css index 64c3164..70ac8dd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -37,7 +37,6 @@ @layer base { body { - /* @apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200; */ @apply bg-[var(--light-background)] text-[var(--light-text)]; } @@ -74,7 +73,7 @@ input, textarea, select { - @apply rounded-lg border border-[var(--light-text)]/20 bg-[var(--light-foreground)] px-3 py-2 text-[var(--light-text)] transition-colors duration-200; + @apply rounded-lg border px-3 py-2 transition-colors duration-200; } input:focus, @@ -82,449 +81,25 @@ @apply ring-2 ring-[var(--light-accent)] outline-none; } - .dark input, - .dark textarea, - .dark select { - @apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)]; - } - .dark input:focus, .dark select:focus { @apply ring-[var(--dark-accent)]; } button { - @apply cursor-pointer rounded-md bg-[var(--light-accent)] px-4 py-2 text-[var(--light-background)] transition-all duration-200 hover:bg-[var(--light-accent)]/80 focus:ring-2 focus:ring-[var(--light-accent)]/50 focus:outline-none disabled:opacity-50; + @apply cursor-pointer rounded-md bg-[var(--light-accent)] px-4 py-2 text-[var(--light-background)] transition-all duration-200 hover:bg-[var(--light-accent)]/80 focus:outline-none disabled:opacity-50; } .dark button { - @apply bg-[var(--dark-accent)] text-[var(--dark-background)] hover:bg-[var(--dark-accent)]/80 focus:ring-[var(--dark-accent)]/50; + @apply bg-[var(--dark-accent)] text-[var(--dark-background)] hover:bg-[var(--dark-accent)]/80; } } -/* Reusable component classes */ @layer components { - .card { - @apply rounded-lg bg-[var(--light-foreground)] p-6 shadow-md transition-colors duration-200; - } + /* * * * * * * * * * * * */ + /* Loading animation */ + /* * * * * * * * * * * * */ - .dark .card { - @apply bg-[var(--dark-foreground)]; - } - - .form-group { - @apply mb-4 space-y-2; - } - - .form-label { - @apply block text-sm font-medium text-[var(--light-text)]; - } - - .dark .form-label { - @apply text-[var(--dark-text)]; - } - - /* Error messages */ - .error { - @apply flex items-center justify-between rounded-lg bg-[var(--light-error-background)] p-3 text-sm text-[var(--light-error-text)]; - } - - .dark .error { - @apply bg-[var(--dark-error-background)] text-[var(--dark-error-text)]; - } - - /* Success messages */ - .success { - @apply flex items-center justify-between rounded-lg bg-[var(--light-success-background)] p-3 text-sm text-[var(--light-success-text)]; - } - - .dark .success { - @apply bg-[var(--dark-success-background)] text-[var(--dark-success-text)]; - } - - .btn-primary { - @apply rounded-lg bg-[var(--light-accent)] text-[var(--light-background)]; - } - - .dark .btn-primary { - @apply bg-[var(--dark-accent)] text-[var(--dark-background)]; - } - - .btn-secondary { - @apply rounded-lg border border-[var(--light-text)]/20 bg-[var(--light-foreground)] text-[var(--light-text)]; - } - - .dark .btn-secondary { - @apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)]; - } - - .container-page { - @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; - } - - /* Sidebar */ - @media (max-width: 768px) { - .sidebar { - @apply w-full max-w-full; - } - - body.sidebar-open { - @apply overflow-hidden; - } - } - - @media (min-width: 768px) { - .sidebar { - @apply w-64; - } - } - - .sidebar { - @apply fixed z-10 flex h-full flex-col overflow-hidden border-r border-[var(--light-foreground)] bg-[var(--light-foreground)] transition-all duration-300; - } - - .dark .sidebar { - @apply border-[var(--dark-foreground)] bg-[var(--dark-foreground)]; - } - - .sidebar-header, - .sidebar-footer { - /* Height should align with the navbar height for visual consistency */ - @apply flex h-20 items-center justify-center border-b border-[var(--light-text)]/20 p-2; - } - - .dark .sidebar-header, - .dark .sidebar-footer { - @apply border-[var(--dark-text)]/20; - } - - .sidebar-footer { - @apply border-t; - } - - .sidebar-item { - @apply cursor-pointer p-3 transition-colors hover:bg-[var(--light-background)]; - } - - .dark .sidebar-item { - @apply hover:bg-[var(--dark-background)]; - } - - .sidebar-item-active { - @apply bg-[var(--light-background)]; - } - - .dark .sidebar-item-active { - @apply bg-[var(--dark-background)]; - } - - .sidebar-item-content { - @apply flex flex-col; - } - - .sidebar-item-bottom-row { - @apply mt-1 flex items-center justify-between; - } - - .sidebar-item-text { - @apply text-xs text-[var(--light-text)]/60; - } - - .dark .sidebar-item-text { - @apply text-[var(--dark-text)]/60; - } - - .sidebar-item-delete { - @apply flex h-6.5 w-6.5 items-center justify-center rounded-lg border-1 border-[var(--light-accent)]/20 bg-transparent p-1 text-[var(--light-accent)]/50 hover:text-[var(--light-accent)]; - } - - .dark .sidebar-item-delete { - @apply border-[var(--dark-accent)]/20 text-[var(--dark-accent)]/50 hover:text-[var(--dark-accent)]; - } - - .sidebar-divider { - @apply divide-y divide-[var(--light-text)]/20; - } - - .dark .sidebar-divider { - @apply divide-[var(--dark-text)]/20; - } - - .search-bar { - @apply w-full rounded-lg py-2.5 pr-4 pl-11; - } - - .search-bar-icon { - @apply absolute top-3 left-8 h-5 w-5 text-[var(--light-text)]/60; - } - - .dark .search-bar-icon { - @apply text-[var(--dark-text)]/60; - } - - .general-sidebar-icon { - @apply h-4 w-4 text-[var(--light-text)]/60; - } - - .dark .general-sidebar-icon { - @apply text-[var(--dark-text)]/60; - } - - /* Versions dropdown */ - .versions-dropdown { - @apply absolute right-0 z-10 mt-2 w-52 origin-top-right space-y-0.5 rounded-md bg-[var(--light-foreground)] shadow-lg ring-1 ring-[var(--light-foreground)]/10 focus:outline-none; - } - - .dark .versions-dropdown { - @apply bg-[var(--dark-foreground)] ring-[var(--dark-foreground)]/10; - } - - .versions-dropdown-item { - @apply w-full bg-[var(--light-foreground)] p-3 transition-colors hover:bg-[var(--light-background)]; - } - - .dark .versions-dropdown-item { - @apply bg-[var(--dark-foreground)] hover:bg-[var(--dark-background)]; - } - - .versions-dropdown-item-active { - @apply bg-[var(--light-accent)]/20; - } - - .dark .versions-dropdown-item-active { - @apply bg-[var(--dark-accent)]/20; - } - - .versions-dropdown-item-text { - @apply text-xs text-[var(--light-text)]/60; - } - - .dark .versions-dropdown-item-text { - @apply text-[var(--dark-text)]/60; - } - - .versions-dropdown-divider { - @apply divide-y divide-[var(--light-text)]/20; - } - - .dark .versions-dropdown-divider { - @apply divide-[var(--dark-text)]/20; - } - - /* Note editor */ - .note-title-container { - @apply mb-4; - } - - .note-title-input { - @apply w-full rounded-2xl border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)]; - } - - .dark .note-title-input { - @apply border-[var(--dark-text)]/20 focus:border-[var(--dark-accent)]; - } - - .note-char-count { - @apply mt-2 text-xs text-[var(--light-text)]/60; - } - - .dark .note-char-count { - @apply text-[var(--dark-text)]/60; - } - - .note-editor-container { - @apply flex min-h-0 flex-1 flex-col overflow-auto; - } - - .note-editor-wrapper { - @apply flex h-full flex-col; - } - - .note-editor-content { - @apply flex h-full flex-col; - } - - .note-textarea { - @apply h-full max-h-full min-h-1/2 w-full resize-none rounded-2xl bg-transparent p-3.5 font-mono outline-none focus:border-4 focus:border-[var(--light-accent)]/60; - } - - .dark .note-textarea { - @apply focus:border-[var(--dark-accent)]/60; - } - - .note-save-button { - @apply fixed right-10 bottom-10 z-10; - } - - /* Markdown preview */ - .markdown-preview h1 { - @apply mt-6 mb-4 border-b border-[var(--light-foreground)] pb-3 text-3xl; - } - - .dark .markdown-preview h1 { - @apply border-[var(--dark-foreground)]; - } - - .markdown-preview h2 { - @apply mt-6 mb-4 text-2xl; - } - - .markdown-preview h3 { - @apply mt-5 mb-3 text-xl; - } - - .markdown-preview p { - @apply my-4; - } - - .markdown-preview ul, - .markdown-preview ol { - @apply my-4 pl-5; - } - - .markdown-preview ul { - @apply list-disc; - } - - .markdown-preview ol { - @apply list-decimal; - } - - .markdown-preview li > ul, - .markdown-preview li > ol, - .markdown-preview ul > ul, - .markdown-preview ul > ol, - .markdown-preview ol > ol, - .markdown-preview ol > ul { - @apply my-1; /* Reduced vertical spacing for nested lists */ - } - - .markdown-preview ul ul:has(> li > input[type="checkbox"]) { - @apply pl-11; - } - - .markdown-preview ul ul ul:has(> li > input[type="checkbox"]) { - @apply pl-11; - } - - .markdown-preview li span { - @apply ml-1.5; - } - - .markdown-preview li:has(> input[type="checkbox"]) { - /* Bullet removal */ - @apply -ml-4.5 list-none; - } - - .markdown-preview input[type="checkbox"] { - /* Actual checkbox styling */ - @apply mr-1 h-4 w-4 appearance-none rounded-full border-[var(--light-text)]/30 bg-[var(--light-foreground)] p-0 align-middle; - } - - .dark .markdown-preview input[type="checkbox"] { - @apply border-[var(--dark-text)]/30 bg-[var(--dark-foreground)]; - } - - .markdown-preview input[type="checkbox"]:checked { - @apply border-[var(--light-accent)] bg-[var(--light-accent)]; - } - - .dark .markdown-preview input[type="checkbox"]:checked { - @apply border-[var(--dark-accent)] bg-[var(--dark-accent)]; - } - - .markdown-preview hr { - @apply border-[var(--light-text)]/20; - } - - .dark .markdown-preview hr { - @apply border-[var(--dark-text)]/20; - } - - .markdown-preview code { - @apply rounded bg-[var(--light-foreground)] px-1 py-0.5 font-mono; - } - - .dark .markdown-preview code { - @apply bg-[var(--dark-foreground)]; - } - - .markdown-preview pre { - @apply overflow-x-auto rounded-2xl bg-[var(--light-foreground)] p-3; - } - - .dark .markdown-preview pre { - @apply bg-[var(--dark-foreground)]; - } - - .markdown-preview blockquote { - @apply my-4 border-l-4 border-[var(--light-accent)] pl-4 text-[var(--light-text)] opacity-70; - } - - .dark .markdown-preview blockquote { - @apply border-[var(--dark-accent)] text-[var(--dark-text)]; - } - - .markdown-preview a { - @apply text-[var(--light-accent)] underline; - } - - .dark .markdown-preview a { - @apply text-[var(--dark-accent)]; - } - - .markdown-preview table { - @apply my-4 w-full border-collapse; - } - - .markdown-preview th, - .markdown-preview td { - @apply border border-[var(--light-text)]/20 p-2 text-left; - } - - .dark .markdown-preview th, - .dark .markdown-preview td { - @apply border-[var(--dark-text)]/20; - } - - .markdown-preview th { - @apply bg-[var(--light-foreground)]; - } - - .dark .markdown-preview th { - @apply bg-[var(--dark-foreground)]; - } - - /* Settings modal */ - .modal-backdrop { - @apply fixed inset-0 z-40 flex items-center justify-center backdrop-blur-xs; - } - - .modal-content { - @apply mx-4 max-h-[90vh] w-full max-w-md overflow-y-auto rounded-lg border-2 border-[var(--light-accent)]/10 bg-[var(--light-background)] shadow-lg; - } - - .dark .modal-content { - @apply border-[var(--dark-accent)]/10 bg-[var(--dark-background)]; - } - - .modal-section { - @apply border-b border-[var(--light-text)]/20 p-4; - } - - .dark .modal-section { - @apply border-[var(--dark-text)]/20; - } - - .modal-close-button { - @apply h-8 w-8 items-center justify-center rounded-md border-1 border-[var(--light-accent)]/20 bg-transparent p-1 text-[var(--light-accent)]/50 hover:text-[var(--light-accent)]; - } - - .dark .modal-close-button { - @apply border-[var(--dark-accent)]/20 text-[var(--dark-accent)]/50 hover:text-[var(--dark-accent)]; - } - - /* Loading spinner */ .loading-container { @apply pointer-events-none flex h-screen w-full items-center justify-center; } @@ -567,7 +142,54 @@ } } - /* Main layout */ + /* * * * * * * * * * * * * */ + /* Authentication view */ + /* * * * * * * * * * * * * */ + + .auth-card { + @apply rounded-lg bg-[var(--light-foreground)] p-6 shadow-md transition-colors duration-200; + } + + .auth-input-field { + @apply w-full rounded-lg border border-[var(--light-accent)]/20 bg-transparent; + } + + .dark .auth-input-field { + @apply border-[var(--dark-accent)]/20; + } + + .auth-button { + @apply w-full rounded-lg border border-[var(--light-accent)]/20 bg-[var(--light-accent)]/70 text-[var(--light-background)]; + } + + .dark .auth-button { + @apply border-[var(--dark-accent)]/20 bg-[var(--dark-accent)]/70 text-[var(--dark-background)]; + } + + .dark .auth-card { + @apply bg-[var(--dark-foreground)]; + } + + /* * * * * * */ + /* Forms */ + /* * * * * * */ + + .form-group { + @apply mb-4 space-y-2; + } + + .form-label { + @apply block text-sm font-medium text-[var(--light-text)]; + } + + .dark .form-label { + @apply text-[var(--dark-text)]; + } + + /* * * * * * * * * * * * * */ + /* Primary view layout */ + /* * * * * * * * * * * * * */ + .main-layout-container { @apply flex h-screen w-full bg-[var(--light-background)]; } @@ -576,20 +198,9 @@ @apply bg-[var(--dark-background)]; } - .content-wrapper { - @apply flex h-screen flex-1 flex-col overflow-hidden; - } - - .note-content-fixed-width { - @apply mx-auto flex w-full max-w-[800px] flex-col px-8 py-0; - height: calc(100% - 5rem); - } - - @media (max-width: 768px) { - .note-content-fixed-width { - @apply max-w-full px-4 py-0; - } - } + /* * * * * * * * * * */ + /* Notifications */ + /* * * * * * * * * * */ .main-info-popup { /* Z-value should be set so that this is on top of the modal's background blur */ @@ -600,18 +211,225 @@ @apply ml-2 h-4 w-4 items-center justify-center rounded-md bg-transparent p-0 text-inherit; } - .main-header { - /* - Should have higher z-value than the contents, but still less than the sidebar - (otherwise the navbar and the sidebar shadows will seem uneven). - */ - @apply z-5 flex h-20 items-center justify-between bg-[var(--light-foreground)] p-4 shadow-sm; + /* Error notifications */ + + .error { + @apply flex items-center justify-between rounded-lg bg-[var(--light-error-background)] p-3 text-sm text-[var(--light-error-text)]; } - .dark .main-header { - @apply bg-[var(--dark-foreground)]; + .dark .error { + @apply bg-[var(--dark-error-background)] text-[var(--dark-error-text)]; } + /* Success notifications */ + + .success { + @apply flex items-center justify-between rounded-lg bg-[var(--light-success-background)] p-3 text-sm text-[var(--light-success-text)]; + } + + .dark .success { + @apply bg-[var(--dark-success-background)] text-[var(--dark-success-text)]; + } + + /* * * * * * * */ + /* Buttons */ + /* * * * * * * */ + + .btn-primary { + @apply rounded-lg bg-[var(--light-accent)] text-[var(--light-background)]; + } + + .dark .btn-primary { + @apply bg-[var(--dark-accent)] text-[var(--dark-background)]; + } + + .btn-secondary { + @apply rounded-lg border border-[var(--light-text)]/20 bg-[var(--light-foreground)] text-[var(--light-text)]; + } + + .dark .btn-secondary { + @apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)]; + } + + /* * * * * * * * * * * * */ + /* Content container */ + /* * * * * * * * * * * * */ + + .container-page { + @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; + } + + /* * * * * * * * * */ + /* Sidebar */ + /* * * * * * * * * */ + + .mini-sidebar { + @apply flex w-14 flex-col items-center border-r border-[var(--light-accent)]/20 bg-[var(--light-foreground)] py-4; + } + + .dark .mini-sidebar { + @apply border-[var(--dark-accent)]/20 bg-[var(--dark-foreground)]; + } + + .sidebar-button { + @apply flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--light-accent)] bg-transparent p-0 text-[var(--light-text)] hover:bg-[var(--light-background)]/80; + } + + .dark .sidebar-button { + @apply border-[var(--dark-accent)]/50 text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80; + } + + .sidebar { + @apply z-10 flex h-full flex-col overflow-hidden border-r border-[var(--light-accent)]/20 bg-[var(--light-foreground)] transition-all duration-300 ease-in-out; + } + + .dark .sidebar { + @apply border-[var(--dark-accent)]/20 bg-[var(--dark-foreground)]; + } + + /* Take up full screen width on mobile */ + @media (max-width: 768px) { + .sidebar { + /* Max width must take into account width of "mini sidebar" */ + @apply w-[calc(100vw-3.5rem)]; + } + + body.sidebar-open { + @apply overflow-hidden; + } + } + + /* 16rem width on desktop or equivalent screens */ + @media (min-width: 768px) { + .sidebar { + @apply w-64; + } + } + + .sidebar-header-container { + @apply flex items-center px-2.5 py-4.5; + } + + .sidebar-header-link { + @apply flex items-center text-inherit no-underline; + } + + .sidebar-header-logo { + @apply ml-2 h-7 w-7; + } + + .sidebar-header-text { + @apply font-copernicus ml-2.5 text-center text-lg text-[var(--light-text)]; + } + + .dark .sidebar-header-text { + @apply text-[var(--dark-text)]; + } + + .sidebar-section-divider { + @apply mx-4 border-t border-[var(--light-text)]/20; + } + + .dark .sidebar-section-divider { + @apply border-[var(--dark-text)]/20; + } + + .sidebar-action-button { + @apply flex items-center rounded-lg border border-[var(--light-text)]/10 bg-transparent px-3 py-2 text-sm text-[var(--light-text)] hover:bg-[var(--light-background)]/50; + } + + .dark .sidebar-action-button { + @apply border-[var(--dark-text)]/10 text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80; + } + + .sidebar-search-bar { + @apply w-full rounded-lg border border-[var(--light-text)]/10 bg-transparent py-2 pr-4 pl-11 text-sm text-[var(--light-text)] hover:bg-[var(--light-background)]/80; + } + + .dark .sidebar-search-bar { + @apply border-[var(--dark-text)]/10 text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80; + } + + .sidebar-search-bar-icon { + @apply absolute top-3 left-6 h-4 w-4 text-[var(--light-text)]/60; + } + + .dark .sidebar-search-bar-icon { + @apply text-[var(--dark-text)]/60; + } + + .sidebar-list { + @apply flex cursor-pointer flex-col rounded-lg border border-[var(--light-text)]/10 bg-transparent px-3 py-2 text-sm text-[var(--light-text)] hover:bg-[var(--light-background)]/50; + } + + .dark .sidebar-list { + @apply border-[var(--dark-text)]/10 bg-transparent text-[var(--dark-text)] hover:bg-[var(--dark-background)]/80; + } + + .sidebar-list-active { + @apply border-[var(--light-accent)]; + } + + .dark .sidebar-list-active { + @apply border-[var(--dark-accent)]; + } + + .sidebar-list-item-title { + @apply font-copernicus truncate text-sm text-[var(--light-text)]; + } + + .dark .sidebar-list-item-title { + @apply text-[var(--dark-text)]; + } + + .sidebar-list-item-delete-button { + @apply ml-1 h-5.5 w-5.5 flex-shrink-0 border-[var(--light-accent)]/40 hover:bg-[var(--light-accent)]/20; + } + + .dark .sidebar-list-item-delete-button { + @apply border-[var(--dark-accent)]/40 hover:bg-[var(--dark-accent)]/20; + } + + .sidebar-list-item-metadata { + @apply mt-1 truncate text-xs text-[var(--light-text)]/40; + } + + .dark .sidebar-list-item-metadata { + @apply text-[var(--dark-text)]/40; + } + + .sidebar-search-info { + @apply p-4 text-center text-sm text-[var(--light-text)]/60; + } + + .dark .sidebar-search-info { + @apply text-[var(--dark-text)]/60; + } + + .sidebar-user-button-content-container { + @apply flex w-full items-center; + } + + .sidebar-user-button-username-container { + @apply max-w-full flex-grow justify-center overflow-hidden px-2; + } + + .sidebar-user-button-username-text { + @apply line-clamp-2 block max-w-full overflow-hidden text-ellipsis; + } + + .sidebar-user-dropdown { + @apply absolute bottom-full left-2 mb-0 w-[95%] rounded-lg border border-[var(--light-accent)]/20 bg-[var(--light-foreground)] shadow-lg; + } + + .dark .sidebar-user-dropdown { + @apply border-[var(--dark-accent)]/20 bg-[var(--dark-foreground)]; + } + + /* * * * * * * * * * * * */ + /* Primary workspace */ + /* * * * * * * * * * * * */ + .main-content { @apply flex min-h-0 flex-1 flex-col overflow-auto bg-[var(--light-background)] p-6; } @@ -620,6 +438,35 @@ @apply bg-[var(--dark-background)]; } + /* Minimize wasted screen height on short displays */ + @media (max-width: 768px) { + .main-content { + @apply pb-2; + } + } + + .content-wrapper { + @apply flex h-screen flex-1 flex-col overflow-hidden py-12; + } + + .note-content-fixed-width { + @apply mx-auto flex h-full w-full max-w-[800px] flex-col px-8 py-0; + } + + @media (max-width: 768px) { + .content-wrapper { + @apply py-0; + } + + .note-content-fixed-width { + @apply max-w-full px-0 py-0; + } + } + + /* * * * * * * * * * * */ + /* User greetings */ + /* * * * * * * * * * * */ + .greeting-container { @apply flex h-full flex-col items-center justify-center; } @@ -629,10 +476,442 @@ } .greeting-bottom-link { - @apply cursor-pointer text-[var(--light-accent)] transition-colors duration-200 hover:underline; + @apply cursor-pointer text-[var(--light-accent)]/70 transition-colors duration-200 hover:underline; } - .greeting-bottom-link { + .dark .greeting-bottom-link { @apply text-[var(--dark-accent)]; } + + /* * * * * * * * * * * * * */ + /* Note editor/viewer */ + /* * * * * * * * * * * * * */ + + .note-editor-content { + @apply flex h-full flex-col; + } + + .note-title-container { + @apply mb-4; + } + + .note-title-display { + @apply font-copernicus max-w-full pb-1 text-2xl wrap-break-word; + } + + /* Limit title (display as input is always only one row tall) to max. 4 rows tall on short screens */ + @media (max-width: 768px) { + .note-title-display { + @apply max-h-24 overflow-y-auto; + line-height: 1.2; + } + } + + .note-title-input { + @apply w-full rounded-lg border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl hover:bg-[var(--light-foreground)]/50 focus:border-1 focus:border-[var(--light-accent)]/50; + } + + .dark .note-title-input { + @apply border-[var(--dark-text)]/20 hover:bg-[var(--dark-foreground)]/80 focus:border-[var(--dark-accent)]/50; + } + + .note-char-count { + @apply my-2 text-xs text-[var(--light-text)]/50; + } + + .dark .note-char-count { + @apply text-[var(--dark-text)]/50; + } + + .note-action-container { + @apply mb-2.5 flex flex-wrap items-center justify-start space-x-2 gap-y-2 border-t border-[var(--light-text)]/10 pt-2.5; + } + + .dark .note-action-container { + @apply border-[var(--dark-text)]/10; + } + + .note-action-button { + @apply flex h-9 items-center justify-center rounded-lg border border-[var(--light-text)]/10 bg-transparent text-sm text-[var(--light-text)] hover:bg-[var(--light-foreground)]/50; + } + + .dark .note-action-button { + @apply border-[var(--dark-text)]/10 text-[var(--dark-text)] hover:bg-[var(--dark-foreground)]/80; + } + + .note-action-icon-button { + @apply flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--light-text)]/10 bg-transparent p-0 text-[var(--light-text)] hover:bg-[var(--light-foreground)]/80; + } + + .dark .note-action-icon-button { + @apply border-[var(--dark-text)]/10 text-[var(--dark-text)] hover:bg-[var(--dark-foreground)]/80; + } + + .note-mobile-save-button { + @apply fixed right-4 bottom-4 z-10 hidden h-10 w-10 items-center justify-center border border-[var(--light-accent)] bg-[var(--light-foreground)] p-0 text-[var(--light-text)]/80 shadow-lg; + } + + .dark .note-mobile-save-button { + @apply border-[var(--dark-accent)]/50 bg-[var(--dark-foreground)] text-[var(--dark-text)]/80; + } + + /* Modified save button positioning on short screens */ + @media (max-width: 768px) { + .note-mobile-save-button { + @apply flex; + } + + .note-action-icon-button { + @apply hidden; + } + } + + .note-versions-dropdown { + @apply absolute top-9 right-0 z-10 mt-2 w-58 origin-top-right overflow-hidden rounded-lg border border-[var(--light-accent)]/20 bg-[var(--light-foreground)] shadow-lg; + } + + .dark .note-versions-dropdown { + @apply border-[var(--dark-accent)]/20 bg-[var(--dark-foreground)]; + } + + .versions-dropdown-item-text { + @apply font-copernicus w-full justify-end truncate px-0 text-[var(--light-text)]/90; + letter-spacing: -0.02em; + } + + .dark .versions-dropdown-item-text { + @apply text-[var(--dark-text)]/90; + } + + .versions-dropdown-item-meta { + @apply text-xs text-[var(--light-text)]/60; + } + + .dark .versions-dropdown-item-meta { + @apply text-[var(--dark-text)]/60; + } + + .versions-dropdown-active-version { + @apply border-[var(--light-accent)]; + } + + .dark .versions-dropdown-active-version { + @apply border-[var(--dark-accent)]; + } + + .note-editor-container { + @apply flex min-h-0 flex-1 flex-col overflow-auto; + } + + .note-editor-wrapper { + @apply flex h-full flex-col; + } + + .note-textarea { + @apply h-full max-h-full min-h-1/2 w-full resize-none rounded-lg border-1 border-[var(--light-text)]/20 bg-transparent p-3.5 font-mono outline-none hover:bg-[var(--light-foreground)]/50 focus:border-3 focus:border-[var(--light-accent)]/70; + } + + .dark .note-textarea { + @apply border-[var(--dark-text)]/20 hover:bg-[var(--dark-foreground)]/80 focus:border-[var(--dark-accent)]/80; + } + + .note-save-button { + @apply fixed right-10 bottom-10 z-10; + } + + /* * * * * * * * * * * * * */ + /* Rendered Markdown */ + /* * * * * * * * * * * * * */ + + .markdown-preview h1, + h2, + h3, + h4 { + @apply font-copernicus; + } + + /* Headings */ + + .markdown-preview h1 { + @apply mt-6 mb-4 border-b border-[var(--light-foreground)] pb-3 text-3xl; + } + + .dark .markdown-preview h1 { + @apply border-[var(--dark-foreground)]; + } + + .markdown-preview h2 { + @apply mt-6 mb-4 text-2xl; + } + + .markdown-preview h3 { + @apply mt-5 mb-3 text-xl; + } + + /* Regular text */ + + .markdown-preview p { + @apply my-4; + } + + /* Regular lists */ + + .markdown-preview ul, + .markdown-preview ol { + @apply pl-5; + } + + .markdown-preview ul { + @apply list-disc; + } + + .markdown-preview ol { + @apply list-decimal; + } + + /* Checkboxes ('todo items') */ + + .markdown-preview input[type="checkbox"] { + @apply mr-1 h-4 w-4 appearance-none rounded-md border-[var(--light-text)]/30 bg-[var(--light-foreground)] p-0 align-text-bottom; + } + + .dark .markdown-preview input[type="checkbox"] { + @apply border-[var(--dark-text)]/30 bg-[var(--dark-foreground)]; + } + + .markdown-preview input[type="checkbox"]:checked { + @apply border-[var(--light-accent)] bg-[var(--light-accent)]; + } + + .dark .markdown-preview input[type="checkbox"]:checked { + @apply border-[var(--dark-accent)] bg-[var(--dark-accent)]/80; + } + + /* Horizontal rules */ + + .markdown-preview hr { + @apply my-4 border-[var(--light-text)]/20; + } + + .dark .markdown-preview hr { + @apply border-[var(--dark-text)]/20; + } + + /* Inline code snippets */ + + .markdown-preview code { + @apply rounded-lg bg-[var(--light-foreground)] px-1 py-0.5 font-mono; + } + + .dark .markdown-preview code { + @apply bg-[var(--dark-foreground)]; + } + + /* Code blocks & blockquotes */ + + .markdown-preview pre code { + @apply block bg-transparent px-0 py-0; + } + + .markdown-preview pre { + @apply overflow-x-auto rounded-lg bg-[var(--light-foreground)] p-2; + } + + .dark .markdown-preview pre { + @apply bg-[var(--dark-foreground)]; + } + + .markdown-preview blockquote { + @apply my-4 rounded-none border-l-4 border-[var(--light-accent)] pl-4 text-[var(--light-text)] opacity-70; + } + + .dark .markdown-preview blockquote { + @apply border-[var(--dark-accent)] text-[var(--dark-text)]; + } + + /* Links */ + + .markdown-preview a { + @apply text-[var(--light-accent)] underline; + } + + .dark .markdown-preview a { + @apply text-[var(--dark-accent)]; + } + + /* Tables & their contents */ + + .markdown-preview table { + @apply my-4 w-full border-collapse; + } + + .markdown-preview th, + .markdown-preview td { + @apply border border-[var(--light-text)]/20 p-2 text-left; + } + + .dark .markdown-preview th, + .dark .markdown-preview td { + @apply border-[var(--dark-text)]/20; + } + + .markdown-preview th { + @apply bg-[var(--light-foreground)]; + } + + .dark .markdown-preview th { + @apply bg-[var(--dark-foreground)]; + } + + /* Codeblock syntax highlighting (highlight.js) */ + + .hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + background: transparent; + color: var(--light-text); + } + + .dark .hljs { + color: var(--dark-text); + } + + /* Keywords */ + + .hljs-keyword, + .hljs-selector-tag, + .hljs-subst { + color: #8959a8; + } + + .dark .hljs-keyword, + .dark .hljs-selector-tag, + .dark .hljs-subst { + color: #c792ea; + } + + /* Strings */ + + .hljs-string, + .hljs-doctag, + .hljs-regexp { + color: #3c8548; + } + + .dark .hljs-string, + .dark .hljs-doctag, + .dark .hljs-regexp { + color: #89ca78; + } + + /* Numbers & booleans */ + + .hljs-number, + .hljs-literal { + color: #f5871f; + } + + .dark .hljs-number, + .dark .hljs-literal { + color: #f78c6c; + } + + /* Function names */ + + .hljs-title, + .hljs-section, + .hljs-selector-id { + color: #4271ae; + } + + .dark .hljs-title, + .dark .hljs-section, + .dark .hljs-selector-id { + color: #82aaff; + } + + /* Comments */ + + .hljs-comment { + color: #8e908c; + } + + .dark .hljs-comment { + color: #676e95; + } + + /* Variables */ + + .hljs-variable, + .hljs-template-variable { + color: #c82829; + } + + .dark .hljs-variable, + .dark .hljs-template-variable { + color: #ff5874; + } + + /* Class names */ + + .hljs-class .hljs-title { + color: #4271ae; + } + + .dark .hljs-class .hljs-title { + color: #ffcb6b; + } + + /* * * * * * * */ + /* Modals */ + /* * * * * * * */ + + .modal-backdrop { + @apply fixed inset-0 z-40 flex items-center justify-center backdrop-blur-xs; + } + + .modal-content { + @apply mx-4 max-h-[90vh] w-full max-w-[90vw] overflow-y-auto rounded-lg border-2 border-[var(--light-accent)]/10 bg-[var(--light-background)] shadow-lg; + } + + .dark .modal-content { + @apply border-[var(--dark-accent)]/10 bg-[var(--dark-background)]; + } + + .modal-section { + @apply border-b border-[var(--light-text)]/20 p-4; + } + + .dark .modal-section { + @apply border-[var(--dark-text)]/20; + } + + .modal-section-title { + @apply mb-4 text-lg; + } + + .modal-table { + @apply w-full table-auto border-collapse; + } + + .modal-table-row { + @apply border border-[var(--light-text)]/10; + } + + .dark .modal-table-row { + @apply border-[var(--dark-text)]/10; + } + + .modal-table-row-item { + @apply overflow-x-auto px-4 py-3 text-wrap; + } + + .modal-table-head { + @apply text-left font-semibold text-[var(--light-text)]/80; + } + + .dark .modal-table-head { + @apply text-[var(--dark-text)]/80; + } } diff --git a/web/src/app.html b/web/src/app.html index 718fae9..9eef4bd 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -21,6 +21,7 @@ localStorage.setItem("darkMode", darkMode) +
%sveltekit.body%
diff --git a/web/src/lib/components/AuthForm.svelte b/web/src/lib/components/AuthForm.svelte index 6e16367..7f70b2a 100644 --- a/web/src/lib/components/AuthForm.svelte +++ b/web/src/lib/components/AuthForm.svelte @@ -1,24 +1,39 @@
-
+
{#if $cError}
{$cError} @@ -62,7 +82,7 @@ placeholder="Username" required autocomplete="username" - class="w-full" + class="auth-input-field" />
@@ -74,11 +94,11 @@ placeholder="Password" required autocomplete={formName === "Login" ? "current-password" : "new-password"} - class="w-full" + class="auth-input-field" />
- @@ -90,7 +110,7 @@
- +
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index bb55478..d0c8700 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -1,19 +1,44 @@ - - + - + + + + +
+ + + + {#if userMenuOpen} + + {/if} +
+ +
diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte index 95b44a4..3f3d353 100644 --- a/web/src/lib/components/ThemeToggle.svelte +++ b/web/src/lib/components/ThemeToggle.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/web/src/lib/icons/ChevronUp.svelte b/web/src/lib/icons/ChevronUp.svelte new file mode 100644 index 0000000..2e598dc --- /dev/null +++ b/web/src/lib/icons/ChevronUp.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/web/src/lib/icons/Close.svelte b/web/src/lib/icons/Close.svelte index ec489dc..8f710aa 100644 --- a/web/src/lib/icons/Close.svelte +++ b/web/src/lib/icons/Close.svelte @@ -1,3 +1,12 @@ - + + + diff --git a/web/src/lib/icons/CreateNew.svelte b/web/src/lib/icons/CreateNew.svelte deleted file mode 100644 index b6de604..0000000 --- a/web/src/lib/icons/CreateNew.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/web/src/lib/icons/Logout.svelte b/web/src/lib/icons/Logout.svelte deleted file mode 100644 index f6efdd0..0000000 --- a/web/src/lib/icons/Logout.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/web/src/lib/icons/ToggleSidebar.svelte b/web/src/lib/icons/ToggleSidebar.svelte deleted file mode 100644 index 9475e14..0000000 --- a/web/src/lib/icons/ToggleSidebar.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/web/src/lib/icons/VersionArrow.svelte b/web/src/lib/icons/VersionArrow.svelte deleted file mode 100644 index 51253c0..0000000 --- a/web/src/lib/icons/VersionArrow.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/web/src/lib/icons/editor/EditPen.svelte b/web/src/lib/icons/editor/EditPen.svelte new file mode 100644 index 0000000..e7e2f19 --- /dev/null +++ b/web/src/lib/icons/editor/EditPen.svelte @@ -0,0 +1,18 @@ + + + + + + diff --git a/web/src/lib/icons/editor/Save.svelte b/web/src/lib/icons/editor/Save.svelte new file mode 100644 index 0000000..86066f4 --- /dev/null +++ b/web/src/lib/icons/editor/Save.svelte @@ -0,0 +1,18 @@ + + + + diff --git a/web/src/lib/icons/editor/ViewEye.svelte b/web/src/lib/icons/editor/ViewEye.svelte new file mode 100644 index 0000000..b3be933 --- /dev/null +++ b/web/src/lib/icons/editor/ViewEye.svelte @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/web/src/lib/icons/sidebar/AdminShield.svelte b/web/src/lib/icons/sidebar/AdminShield.svelte new file mode 100644 index 0000000..603740a --- /dev/null +++ b/web/src/lib/icons/sidebar/AdminShield.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/web/src/lib/icons/sidebar/ChevronLeft.svelte b/web/src/lib/icons/sidebar/ChevronLeft.svelte new file mode 100644 index 0000000..8f5a0ee --- /dev/null +++ b/web/src/lib/icons/sidebar/ChevronLeft.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/web/src/lib/icons/sidebar/ChevronRight.svelte b/web/src/lib/icons/sidebar/ChevronRight.svelte new file mode 100644 index 0000000..fd1a069 --- /dev/null +++ b/web/src/lib/icons/sidebar/ChevronRight.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/web/src/lib/icons/sidebar/Create.svelte b/web/src/lib/icons/sidebar/Create.svelte new file mode 100644 index 0000000..c9a76e2 --- /dev/null +++ b/web/src/lib/icons/sidebar/Create.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/web/src/lib/icons/Delete.svelte b/web/src/lib/icons/sidebar/Delete.svelte similarity index 75% rename from web/src/lib/icons/Delete.svelte rename to web/src/lib/icons/sidebar/Delete.svelte index c5f3eda..c078fcf 100644 --- a/web/src/lib/icons/Delete.svelte +++ b/web/src/lib/icons/sidebar/Delete.svelte @@ -1,4 +1,14 @@ - + + + diff --git a/web/src/lib/icons/sidebar/Exit.svelte b/web/src/lib/icons/sidebar/Exit.svelte new file mode 100644 index 0000000..58b2bc0 --- /dev/null +++ b/web/src/lib/icons/sidebar/Exit.svelte @@ -0,0 +1,21 @@ + + + + + + + diff --git a/web/src/lib/icons/Search.svelte b/web/src/lib/icons/sidebar/Search.svelte similarity index 73% rename from web/src/lib/icons/Search.svelte rename to web/src/lib/icons/sidebar/Search.svelte index c6375c6..531e350 100644 --- a/web/src/lib/icons/Search.svelte +++ b/web/src/lib/icons/sidebar/Search.svelte @@ -1,8 +1,12 @@ + + + export let classString: string + + + export let classString = "h-6 w-6" + + + + + diff --git a/web/src/lib/icons/sidebar/User.svelte b/web/src/lib/icons/sidebar/User.svelte new file mode 100644 index 0000000..a9a12e9 --- /dev/null +++ b/web/src/lib/icons/sidebar/User.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/web/src/lib/icons/sidebar/WebhookTruck.svelte b/web/src/lib/icons/sidebar/WebhookTruck.svelte new file mode 100644 index 0000000..af50142 --- /dev/null +++ b/web/src/lib/icons/sidebar/WebhookTruck.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/web/src/lib/icons/Moon.svelte b/web/src/lib/icons/theme/Moon.svelte similarity index 100% rename from web/src/lib/icons/Moon.svelte rename to web/src/lib/icons/theme/Moon.svelte diff --git a/web/src/lib/icons/Sun.svelte b/web/src/lib/icons/theme/Sun.svelte similarity index 100% rename from web/src/lib/icons/Sun.svelte rename to web/src/lib/icons/theme/Sun.svelte diff --git a/web/src/lib/client.ts b/web/src/lib/logic/client.ts similarity index 76% rename from web/src/lib/client.ts rename to web/src/lib/logic/client.ts index 60cb628..062a101 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/logic/client.ts @@ -1,100 +1,37 @@ import { get, writable, type Writable } from "svelte/store" -import { - API_BASE_ADDR, - AT_EXP_MS, - COOKIE_SAME_SITE, - COOKIE_SECURE, - CSRF_EXP_MS, - REFRESH_BUF, - UUID_REGEX, - VIEW_COOKIE_DOMAIN, - VIEW_COOKIE_PATH -} from "./const" import { goto } from "$app/navigation" -import { usersPagination } from "./pages" +import { usersPagination } from "../util/itemPagination" +import { + FullNote, + NoteMetadata, + User, + VersionMetadata, + type ApiFullNoteResponse, + type ApiFullVersionResponse, + type ApiNoteMetadataResponse, + type ApiUserResponse, + type ApiVersionMetadataResponse, + type NewNoteResponse +} from "./model" -interface User { - id: string - username: string - isAdmin: boolean - createdAt: Date - updatedAt: Date -} +const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" +const UUID_REGEX = new RegExp( + "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "i" +) -interface ApiUserResponse { - id: string - username: string - is_admin: boolean - created_at: string - updated_at: string -} +// lifetimes of *in-memory* authentication tokens in milliseconds +const AT_EXP_MS = 15 * 60 * 1000 // 15 min. +const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h. +const REFRESH_BUF = 30 * 1000 // 30 s. -export interface FullNote { - id: string - owner: string - title: string - content: string - versionNumber: number - versionCreatedAt: Date - isActiveVersion: boolean - noteCreatedAt: Date - noteUpdatedAt: Date -} +// view cookie configuration (holds UNIX timestamp value of the actual refresh token cookie's expiration date) +const VIEW_COOKIE_PATH = import.meta.env.VITE_VIEW_COOKIE_PATH || "/" +const VIEW_COOKIE_DOMAIN = import.meta.env.VITE_VIEW_COOKIE_DOMAIN || "localhost" +const COOKIE_SAME_SITE = import.meta.env.VITE_COOKIE_SAME_SITE || "strict" +const COOKIE_SECURE = import.meta.env.PROD ? true : false -interface ApiFullNoteResponse { - note_id: string - owner_id: string - title: string - content: string - version_number: number - version_created_at: string - note_created_at: string - note_updated_at: string -} - -export interface NoteMetadata { - id: string - owner: string - title: string - updatedAt: Date -} - -interface ApiNoteMetadataResponse { - note_id: string - owner_id: string - title: string - updated_at: string -} - -interface NewNoteResponse { - title: string - content: string -} - -interface VersionMetadata { - versionID: string - title: string - versionNumber: number - isActive: boolean - createdAt: Date -} - -interface ApiVersionMetadataResponse { - version_id: string - title: string - version_number: number - created_at: string -} - -interface ApiFullVersionResponse { - version_id: string - title: string - content: string - version_number: number - created_at: string -} - -// Some of these could just be local variables as not all of them are being used globally +// some of these could just be local variables as not all of them are being used globally export const currentUser: Writable = writable(null) export const currentFullNote: Writable = writable(null) export const availableNotes: Writable = writable(null) @@ -108,13 +45,13 @@ export const cSuccess: Writable = writable(null) class ApiClient { private viewCookieName: string private baseUrl: string - private lastAtUpdate = new Date(0) // Refreshing the page wipes access and CSRF tokens from memory -> Rotation needed + private lastAtUpdate = new Date(0) // refreshing the page wipes access and CSRF tokens from memory -> Rotation needed private lastCsrfUpdate = new Date(0) private refreshInProgress = false private activeVersion = -1 private loadedNotesCache = new Map() private loadedHistoryCache = new Map() - private loadedVersionsCache = new Map() // Key: noteID + versionID + private loadedVersionsCache = new Map() // key: noteID + versionID constructor(baseUrl: string) { this.baseUrl = baseUrl @@ -129,12 +66,9 @@ class ApiClient { cError.set(null) cSuccess.set(null) - // NOTE: If `handleResponse` is used, errors thrown from it will be caught here + // NOTE: if `handleResponse` is used, errors thrown from it will be caught here try { - // Only bearers (JWT access tokens) need to be considered here as the refresh token cookies - // will be automatically included to corresponding requests by browser and CSRF rotation is - // handled by the `refreshAccessToken` method if (options.useBearerAuth) { try { await this.checkAndRefreshAccessToken() @@ -149,9 +83,9 @@ class ApiClient { } catch (err) { const errMsg = err instanceof Error ? err.message : "Unknown error" - // The suspension option is handy when we want to display the error inside a modal instead of in a global notification + // the suspension option is handy when we want to display the error inside a modal instead of in a global notification if (options.suspendGlobalErr) { - // Throw the same error to the next handler (should be handled inside the caller component) + // throw the same error to the next handler (should be handled inside the caller component) throw new Error(errMsg) } else { cError.set(errMsg) @@ -165,14 +99,14 @@ class ApiClient { return null } - // Should be attached to routes that handle authentication with the bearer token (access token) + // should be attached to routes that handle authentication with the bearer token (access token) private async handleResponse( response: Response, options: { useBearerAuth: boolean } ): Promise { if (!response.ok) { if (response.status === 401 && options.useBearerAuth) { - // This should never happen due to the token expiration checks we make client-side, + // this should never happen due to the token expiration checks we make client-side, // but it's still good to have as a fallback try { console.log("[RES] Unexpected 401 caught, attempting to refresh...") @@ -184,7 +118,7 @@ class ApiClient { } } - // Capitalize the error message and display (only if not 401) + // capitalize the error message and display (only if not 401) const { error } = await response.json() const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "." @@ -195,7 +129,7 @@ class ApiClient { } private async checkAndRefreshAccessToken(): Promise { - // Notably we must check whether we even have an authentication cookie present + // notably we must check whether we even have an authentication cookie present // as that's obviously required for completing the token rotation procedure if (this.refreshInProgress) { return @@ -317,7 +251,7 @@ class ApiClient { return } - // Overwrite the view cookie with details that match with the real cookie + // overwrite the view cookie with details that match with the real cookie document.cookie = `${this.viewCookieName}=;path=${VIEW_COOKIE_PATH};domain=${VIEW_COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;${COOKIE_SECURE ? "secure;" : ""}sameSite=${COOKIE_SAME_SITE}` } @@ -333,77 +267,18 @@ class ApiClient { return cookieValue } - private deserializeUser(apiResponse: ApiUserResponse): User { - return { - id: apiResponse.id, - username: apiResponse.username, - isAdmin: apiResponse.is_admin, - createdAt: new Date(apiResponse.created_at), - updatedAt: new Date(apiResponse.updated_at) - } - } - - private deserializeNoteMetadatas(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] { - return apiResponses.map((res) => { - return { - id: res.note_id, - owner: res.owner_id, - title: res.title, - updatedAt: new Date(res.updated_at) - } - }) - } - - private deserializeFullNote(apiResponse: ApiFullNoteResponse): FullNote { - return { - id: apiResponse.note_id, - owner: apiResponse.owner_id, - title: apiResponse.title, - content: apiResponse.content, - versionNumber: apiResponse.version_number, - versionCreatedAt: new Date(apiResponse.version_created_at), - isActiveVersion: true, // This endpoint serves only the latest version - noteCreatedAt: new Date(apiResponse.note_created_at), - noteUpdatedAt: new Date(apiResponse.note_updated_at) - } - } - - private deserializeVersionMetadatas( - apiResponses: ApiVersionMetadataResponse[] - ): VersionMetadata[] { - return apiResponses.map((res) => { - return { - versionID: res.version_id, - title: res.title, - versionNumber: res.version_number, - isActive: this.activeVersion === res.version_number, - createdAt: new Date(res.created_at) - } - }) - } - private joinDeserializedVersion( noteID: string, apiResponse: ApiFullVersionResponse ): FullNote | null { - // Cache lookups are safe here due to this always being called *after* fetching the actual `FullNote` + // cache lookups are safe here due to this always being called *after* fetching the actual `FullNote` const cachedNote = this.loadedNotesCache.get(noteID) if (!cachedNote) { return null } - return { - id: cachedNote.id, - owner: cachedNote.owner, - title: apiResponse.title, - content: apiResponse.content, - versionNumber: apiResponse.version_number, - versionCreatedAt: new Date(apiResponse.created_at), - isActiveVersion: cachedNote.versionNumber === apiResponse.version_number, - noteCreatedAt: cachedNote.noteCreatedAt, - noteUpdatedAt: cachedNote.noteUpdatedAt - } + return cachedNote.joinWithVersionResponse(apiResponse) } public async register(username: string, password: string): Promise { @@ -415,7 +290,7 @@ class ApiClient { body: JSON.stringify({ username, password }) }) - // Can't overwrite the function parameter + // can't overwrite the function parameter const data = await this.handleResponse<{ id: string username: string @@ -485,7 +360,7 @@ class ApiClient { } }) const data = await this.handleResponse(response, { useBearerAuth: true }) - const user = this.deserializeUser(data) + const user = new User(data) currentUser.set(user) }, @@ -522,7 +397,7 @@ class ApiClient { currentUser.set(user || null) this.lastAtUpdate = new Date() }, - { useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal + { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal ) } @@ -547,7 +422,7 @@ class ApiClient { await this.handleResponse(response, { useBearerAuth: true }) await this.handleLocalLogout() }, - { useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal + { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal ) } @@ -573,7 +448,7 @@ class ApiClient { return users }, - { useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal + { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal ) } @@ -603,7 +478,7 @@ class ApiClient { await this.handleResponse(response, { useBearerAuth: true }) }, - { useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal + { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal ) } @@ -625,7 +500,7 @@ class ApiClient { }) if (data) { - notes = this.deserializeNoteMetadatas(data) + notes = NoteMetadata.fromApiResponseArray(data) } console.log(`[NOTE] Got ${notes.length} note metadata results`) @@ -637,7 +512,7 @@ class ApiClient { } public async createNote(): Promise { - // NOTE: The initial note version doesn't allow any user input, the first user-made modification + // NOTE: the initial note version doesn't allow any user input, the first user-made modification // is applied through the version creation endpoint return this.handleRequest( @@ -659,7 +534,7 @@ class ApiClient { throw new Error("Invalid note ID format.") } - // Attempt cache lookup only if we didn't just push new updates + // attempt cache lookup only if we didn't just push new updates if (!fetchRemote) { const cachedNote = this.loadedNotesCache.get(noteID) if (cachedNote != null) { @@ -679,7 +554,7 @@ class ApiClient { const data = await this.handleResponse(response, { useBearerAuth: true }) - const note = this.deserializeFullNote(data) + const note = new FullNote(data) console.log(`[CACHE] Storing ${noteID}`) this.loadedNotesCache.set(noteID, note) @@ -742,7 +617,7 @@ class ApiClient { const data = await this.handleResponse(response, { useBearerAuth: true }) - const versions = this.deserializeVersionMetadatas(data) + const versions = VersionMetadata.fromApiResponseArray(data, this.activeVersion) this.loadedHistoryCache.set(noteID, versions) console.log(`[VER] Got and cached ${versions.length} version metadata results`) @@ -758,7 +633,7 @@ class ApiClient { throw new Error("Invalid note ID format.") } - // NOTE: Title's length limit is applied in the UI component, so we don't need to worry about it here + // NOTE: title's length limit is applied in the UI component, so we don't need to worry about it here return this.handleRequest( async () => { @@ -782,7 +657,11 @@ class ApiClient { ) } - public async getFullVersion(noteID: string, versionID: string): Promise { + public async getFullVersion( + noteID: string, + versionID: string, + isActiveVersion: boolean + ): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } @@ -791,10 +670,15 @@ class ApiClient { throw new Error("Invalid version ID format.") } - // NOTE: No need to explicitly prevent attempting a cache hit as versions aren't editable + if (isActiveVersion) { + // the active version will always get cached when the note is initially selected and loaded + const cachedActiveNote = this.loadedNotesCache.get(noteID) + if (cachedActiveNote !== undefined) { + return cachedActiveNote + } + } - // TODO: If accessing the active version, don't attempt to hit the cache, - // but instead load the contents from the currently active full note + // NOTE: no need to explicitly prevent attempting a cache hit as versions aren't editable const cachedVersion = this.loadedVersionsCache.get(noteID + versionID) if (cachedVersion != null) { diff --git a/web/src/lib/logic/model.ts b/web/src/lib/logic/model.ts new file mode 100644 index 0000000..a83b061 --- /dev/null +++ b/web/src/lib/logic/model.ts @@ -0,0 +1,144 @@ +export interface ApiUserResponse { + id: string + username: string + is_admin: boolean + created_at: string + updated_at: string +} + +export class User { + id: string + username: string + isAdmin: boolean + createdAt: Date + updatedAt: Date + + constructor(apiResponse: ApiUserResponse) { + this.id = apiResponse.id + this.username = apiResponse.username + this.isAdmin = apiResponse.is_admin + this.createdAt = new Date(apiResponse.created_at) + this.updatedAt = new Date(apiResponse.updated_at) + } +} + +export interface ApiFullNoteResponse { + note_id: string + owner_id: string + title: string + content: string + version_number: number + version_created_at: string + note_created_at: string + note_updated_at: string +} + +export class FullNote { + id: string + owner: string + title: string + content: string + versionNumber: number + versionCreatedAt: Date + isActiveVersion: boolean + noteCreatedAt: Date + noteUpdatedAt: Date + + constructor(apiResponse: ApiFullNoteResponse) { + this.id = apiResponse.note_id + this.owner = apiResponse.owner_id + this.title = apiResponse.title + this.content = apiResponse.content + this.versionNumber = apiResponse.version_number + this.versionCreatedAt = new Date(apiResponse.version_created_at) + this.isActiveVersion = true // this endpoint serves only the latest version + this.noteCreatedAt = new Date(apiResponse.note_created_at) + this.noteUpdatedAt = new Date(apiResponse.note_updated_at) + } + + public joinWithVersionResponse(apiResponse: ApiFullVersionResponse): FullNote { + const newNote = new FullNote({ + note_id: this.id, + owner_id: this.owner, + title: apiResponse.title, + content: apiResponse.content, + version_number: apiResponse.version_number, + version_created_at: apiResponse.created_at, + note_created_at: this.noteCreatedAt.toISOString(), + note_updated_at: this.noteUpdatedAt.toISOString() + }) + + // override the isActiveVersion property + newNote.isActiveVersion = this.versionNumber === apiResponse.version_number + + return newNote + } +} + +export interface ApiNoteMetadataResponse { + note_id: string + owner_id: string + title: string + updated_at: string +} + +export class NoteMetadata { + id: string + owner: string + title: string + updatedAt: Date + + constructor(apiResponse: ApiNoteMetadataResponse) { + this.id = apiResponse.note_id + this.owner = apiResponse.owner_id + this.title = apiResponse.title + this.updatedAt = new Date(apiResponse.updated_at) + } + + static fromApiResponseArray(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] { + return apiResponses.map((res) => new NoteMetadata(res)) + } +} + +export interface NewNoteResponse { + title: string + content: string +} + +export interface ApiVersionMetadataResponse { + version_id: string + title: string + version_number: number + created_at: string +} + +export class VersionMetadata { + versionID: string + title: string + versionNumber: number + isActive: boolean + createdAt: Date + + constructor(apiResponse: ApiVersionMetadataResponse, activeVersionNumber: number) { + this.versionID = apiResponse.version_id + this.title = apiResponse.title + this.versionNumber = apiResponse.version_number + this.isActive = activeVersionNumber === apiResponse.version_number + this.createdAt = new Date(apiResponse.created_at) + } + + static fromApiResponseArray( + apiResponses: ApiVersionMetadataResponse[], + activeVersionNumber: number + ): VersionMetadata[] { + return apiResponses.map((res) => new VersionMetadata(res, activeVersionNumber)) + } +} + +export interface ApiFullVersionResponse { + version_id: string + title: string + content: string + version_number: number + created_at: string +} diff --git a/web/src/lib/utils.ts b/web/src/lib/util/authValidation.ts similarity index 53% rename from web/src/lib/utils.ts rename to web/src/lib/util/authValidation.ts index 4a2db96..4f336db 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/util/authValidation.ts @@ -1,9 +1,34 @@ -import { - ENTROPY_CLASSES, - MAX_PASSWORD_LENGTH, - MIN_PASSWORD_ENTROPY, - MIN_PASSWORD_LENGTH -} from "./const" +const MIN_USERNAME_LENGTH = 3 +const MAX_USERNAME_LENGTH = 20 +const USERNAME_REGEX = RegExp("^[a-z0-9_]+$") + +const MIN_PASSWORD_LENGTH = 12 +const MAX_PASSWORD_LENGTH = 72 +const MIN_PASSWORD_ENTROPY = 60.0 + +// purposefully produce lower entropy than in the backend to prevent 400s +const ENTROPY_CLASSES: Array<[RegExp, number]> = [ + [/[a-z]/, 24], // 26 + [/[A-Z]/, 24], // 26 + [/\d/, 10], + [/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40 +] + +export const isUsernameValid = (username: string): [boolean, string] => { + if (username.length < MIN_USERNAME_LENGTH) { + return [false, `Username cannot be shorter than ${MIN_USERNAME_LENGTH} characters`] + } + + if (username.length > MAX_USERNAME_LENGTH) { + return [false, `Username cannot be longer than ${MAX_USERNAME_LENGTH} characters`] + } + + if (!USERNAME_REGEX.test(username)) { + return [false, "Username can only contain numbers, letters, and underscores"] + } + + return [true, ""] +} export const isPasswordValid = (password: string): [boolean, string] => { if (password.length < MIN_PASSWORD_LENGTH) { @@ -35,7 +60,7 @@ const calculateEntropy = (password: string): number => { } } - // Empty password exception + // empty password exception if (poolSize === 0) { return 0 } diff --git a/web/src/lib/util/contentValidation.ts b/web/src/lib/util/contentValidation.ts new file mode 100644 index 0000000..fb138c4 --- /dev/null +++ b/web/src/lib/util/contentValidation.ts @@ -0,0 +1,13 @@ +export const hashContent = async (content: string) => { + // content string to byte array + const encoder = new TextEncoder() + const data = encoder.encode(content) + + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + + // hash bytes to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") + + return hashHex +} diff --git a/web/src/lib/util/contentVisual.ts b/web/src/lib/util/contentVisual.ts new file mode 100644 index 0000000..c239e05 --- /dev/null +++ b/web/src/lib/util/contentVisual.ts @@ -0,0 +1,15 @@ +export const formatDate = (dateString: string | Date): string => { + if (!dateString) { + return "" + } + + const d = new Date(dateString) + return d.toLocaleDateString(undefined, { + weekday: "short", + year: "2-digit", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric" + }) +} diff --git a/web/src/lib/util/greetMessage.ts b/web/src/lib/util/greetMessage.ts index 19e296c..95fa47d 100644 --- a/web/src/lib/util/greetMessage.ts +++ b/web/src/lib/util/greetMessage.ts @@ -1,7 +1,7 @@ interface HolidayMap { [month: number]: { [day: number]: { - name: string // Metadata for debugging + name: string // metadata for debugging greetings: string[] } } @@ -48,8 +48,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu greetings: [ `Happy May Day, ${username}!`, `Spring celebrations await, ${username}!`, - `Enjoy the festivities, ${username}!`, - `Hyvää Vappua, ${username}!` + `Enjoy the festivities, ${username}!` ] } }, @@ -110,8 +109,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu `Happy Midsummer, ${username}!`, `Enjoying the longest days, ${username}?`, `Summer solstice greetings, ${username}!`, - `Glad Midsommar, ${username}!`, - `Hyvää Juhannusta, ${username}!` + `Glad Midsommar, ${username}!` ]) ] } @@ -127,7 +125,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu } const calculateMidsummer = (year: number): number => { - // Saturday between 20th and 26th of June + // saturday between 20th and 26th of June const startDate = new Date(year, 5, 20) const dayOfWeek = startDate.getDay() @@ -142,7 +140,7 @@ const calculateMidsummer = (year: number): number => { } const gaussEaster = (year: number): [day: number, month: number] => { - // Directly from Gauss's Easter algorithm + // directly from Gauss's Easter algorithm const a = year % 19 const b = year % 4 const c = year % 7 @@ -163,7 +161,7 @@ const gaussEaster = (year: number): [day: number, month: number] => { } if (days > 31) { - // Jump to April + // jump to April return [days - 31, 4] } @@ -173,7 +171,7 @@ const gaussEaster = (year: number): [day: number, month: number] => { const getTimeBasedGreeting = (username: string, date: Date): string => { const hour = date.getHours() - // Early morning + // early morning if (0 <= hour && hour < 5) { return getRandomGreeting([ `Up late, ${username}?`, @@ -183,7 +181,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => { ]) } - // Morning + // morning if (5 <= hour && hour < 12) { return getRandomGreeting([ `Good morning, ${username}!`, @@ -193,7 +191,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => { ]) } - // Afternoon + // afternoon if (12 <= hour && hour < 17) { return getRandomGreeting([ `Good afternoon, ${username}!`, @@ -203,7 +201,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => { ]) } - // Evening + // evening if (17 <= hour && hour < 21) { return getRandomGreeting([ `Good evening, ${username}!`, @@ -213,7 +211,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => { ]) } - // Night + // night return getRandomGreeting([ `Good night, ${username}!`, `Having a pleasant evening, ${username}?`, diff --git a/web/src/lib/pages.ts b/web/src/lib/util/itemPagination.ts similarity index 75% rename from web/src/lib/pages.ts rename to web/src/lib/util/itemPagination.ts index db00a5c..e620a09 100644 --- a/web/src/lib/pages.ts +++ b/web/src/lib/util/itemPagination.ts @@ -3,10 +3,10 @@ import { writable } from "svelte/store" interface PaginationState { currentPage: number pageSize: number - totalItems?: number // Page number rendering (+ caching metadata) + totalItems?: number // page number rendering (+ caching metadata) } -// Limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults, +// limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults, // but should be adjusted based on the UI state/dimensions export const notesPagination = writable({ diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index c49b8f1..9da9ae6 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -1,6 +1,6 @@