diff --git a/@codexteam/ui/dev/index.html b/@codexteam/ui/dev/index.html index 40e8bba0..4bac4872 100644 --- a/@codexteam/ui/dev/index.html +++ b/@codexteam/ui/dev/index.html @@ -1,5 +1,6 @@ Codex UI + import { createApp } from 'vue'; import { createRouter, createWebHashHistory } from 'vue-router'; - import "../src/styles/fonts.pcss"; import "../src/styles/index.pcss"; import "../src/styles/themes/index.pcss"; diff --git a/@codexteam/ui/package.json b/@codexteam/ui/package.json index 70c7ea3f..cfd0cee3 100644 --- a/@codexteam/ui/package.json +++ b/@codexteam/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codexteam/ui", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "sideEffects": [ "*.css", diff --git a/@codexteam/ui/src/fonts/Inter/Inter-Bold.woff2 b/@codexteam/ui/public/fonts/Inter/Inter-Bold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/Inter-Bold.woff2 rename to @codexteam/ui/public/fonts/Inter/Inter-Bold.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/Inter-Regular.woff2 b/@codexteam/ui/public/fonts/Inter/Inter-Regular.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/Inter-Regular.woff2 rename to @codexteam/ui/public/fonts/Inter/Inter-Regular.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/Inter-SemiBold.woff2 b/@codexteam/ui/public/fonts/Inter/Inter-SemiBold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/Inter-SemiBold.woff2 rename to @codexteam/ui/public/fonts/Inter/Inter-SemiBold.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/InterDisplay-Bold.woff2 b/@codexteam/ui/public/fonts/Inter/InterDisplay-Bold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/InterDisplay-Bold.woff2 rename to @codexteam/ui/public/fonts/Inter/InterDisplay-Bold.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/InterDisplay-Regular.woff2 b/@codexteam/ui/public/fonts/Inter/InterDisplay-Regular.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/InterDisplay-Regular.woff2 rename to @codexteam/ui/public/fonts/Inter/InterDisplay-Regular.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/InterDisplay-SemiBold.woff2 b/@codexteam/ui/public/fonts/Inter/InterDisplay-SemiBold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/InterDisplay-SemiBold.woff2 rename to @codexteam/ui/public/fonts/Inter/InterDisplay-SemiBold.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/InterVariable-Italic.woff2 b/@codexteam/ui/public/fonts/Inter/InterVariable-Italic.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/InterVariable-Italic.woff2 rename to @codexteam/ui/public/fonts/Inter/InterVariable-Italic.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/InterVariable.woff2 b/@codexteam/ui/public/fonts/Inter/InterVariable.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/Inter/InterVariable.woff2 rename to @codexteam/ui/public/fonts/Inter/InterVariable.woff2 diff --git a/@codexteam/ui/src/fonts/Inter/inter.css b/@codexteam/ui/public/fonts/Inter/inter.css similarity index 100% rename from @codexteam/ui/src/fonts/Inter/inter.css rename to @codexteam/ui/public/fonts/Inter/inter.css diff --git a/@codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 b/@codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 rename to @codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 diff --git a/@codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 b/@codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 rename to @codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 diff --git a/@codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 b/@codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 similarity index 100% rename from @codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 rename to @codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 diff --git a/@codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono.css b/@codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono.css similarity index 100% rename from @codexteam/ui/src/fonts/JetBrainsMono/JetBrainsMono.css rename to @codexteam/ui/public/fonts/JetBrainsMono/JetBrainsMono.css diff --git a/@codexteam/ui/src/styles/fonts.pcss b/@codexteam/ui/public/styles/fonts.css similarity index 100% rename from @codexteam/ui/src/styles/fonts.pcss rename to @codexteam/ui/public/styles/fonts.css diff --git a/@codexteam/ui/src/vue/components/avatar/Avatar.vue b/@codexteam/ui/src/vue/components/avatar/Avatar.vue index b0e67f6f..27e2d55c 100644 --- a/@codexteam/ui/src/vue/components/avatar/Avatar.vue +++ b/@codexteam/ui/src/vue/components/avatar/Avatar.vue @@ -1,16 +1,25 @@ diff --git a/@codexteam/ui/src/vue/components/button/Button.vue b/@codexteam/ui/src/vue/components/button/Button.vue index 43e86cac..dda64c1b 100644 --- a/@codexteam/ui/src/vue/components/button/Button.vue +++ b/@codexteam/ui/src/vue/components/button/Button.vue @@ -194,5 +194,10 @@ const iconType = computed<'none' | 'leading' | 'trailing' | 'leadingTrailing' | &:hover { background-color: var(--bg-hover); } + + &:focus-visible { + outline: 2px solid var(--accent--solid); + outline-offset: 2px; + } } diff --git a/@codexteam/ui/src/vue/components/confirm/Confirm.vue b/@codexteam/ui/src/vue/components/confirm/Confirm.vue index ff236dc4..d2d2c6c1 100644 --- a/@codexteam/ui/src/vue/components/confirm/Confirm.vue +++ b/@codexteam/ui/src/vue/components/confirm/Confirm.vue @@ -14,17 +14,13 @@ secondary @click="onCancel" > -
- {{ cancelText }} -
+ {{ cancelText }} @@ -56,6 +52,11 @@ const props = withDefaults( */ cancelText?: string; + /** + * Style the confirm button as a negative action + */ + destructive?: boolean; + /** * Function that is executed after pressing the Cancel button */ @@ -69,6 +70,7 @@ const props = withDefaults( { confirmText: 'Confirm', cancelText: 'Cancel', + destructive: false, } ); @@ -114,21 +116,23 @@ onUnmounted(() => { flex-direction: column; gap: var(--v-padding); text-align: center; - width: min-content; + width: 340px; &__body { padding: 0 var(--spacing-ml) 0 var(--spacing-ml); word-wrap: break-word; + color: var(--base--text-secondary); } &__controls { display: flex; padding: var(--v-padding) 0 0 0; gap: var(--spacing-m); - } - &__button-inner { - width: 84px; + & > button { + flex: 1; + justify-content: center; + } } } diff --git a/@codexteam/ui/src/vue/components/confirm/useConfirm.ts b/@codexteam/ui/src/vue/components/confirm/useConfirm.ts index 64049ee4..77b47ad3 100644 --- a/@codexteam/ui/src/vue/components/confirm/useConfirm.ts +++ b/@codexteam/ui/src/vue/components/confirm/useConfirm.ts @@ -1,28 +1,57 @@ import { createSharedComposable } from '@vueuse/core'; +import { watch } from 'vue'; import { usePopup } from '../popup'; import { Confirm } from '.'; +/** + * Optional texts and style of the confirm window + */ +export interface ConfirmOptions { + /** + * Text of the confirm button + */ + confirmText?: string; + + /** + * Text of the cancel button + */ + cancelText?: string; + + /** + * Style the confirm button as a negative action + */ + destructive?: boolean; +} + export const useConfirm = createSharedComposable(() => { /** * Used to create a Popup component that will display the current Confirm */ - const { showPopup, hidePopup } = usePopup(); + const { showPopup, hidePopup, isOpen } = usePopup(); /** * @param title - title of the confirm window * @param text - message to be displayed in confirm window - * @returns user selection result + * @param options - button texts and style + * @returns user selection result, false if the popup was closed without a choice */ - async function confirm(title: string, text: string): Promise { + async function confirm(title: string, text: string, options: ConfirmOptions = {}): Promise { return new Promise((resolve) => { + const stop = watch(isOpen, (open) => { + if (!open) { + stop(); + resolve(false); + } + }); + showPopup({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment component: Confirm, props: { + ...options, title: title, text: text, onCancel: () => { - resolve(false); hidePopup(); }, onConfirm: () => { diff --git a/@codexteam/ui/src/vue/components/editor/Editor.vue b/@codexteam/ui/src/vue/components/editor/Editor.vue index cd3c1b08..241642a3 100644 --- a/@codexteam/ui/src/vue/components/editor/Editor.vue +++ b/@codexteam/ui/src/vue/components/editor/Editor.vue @@ -73,7 +73,7 @@ const editorConfig = computed(() => { }; }); -const { isEmpty } = useEditor(editorConfig, { +const { isEmpty, save } = useEditor(editorConfig, { onChange: data => emit('change', data), }); @@ -85,6 +85,11 @@ defineExpose({ return isEmpty.value; }, + /** + * Returns the current content, including changes not yet reported by the change event + */ + save, + /** * Returns the editor holder */ diff --git a/@codexteam/ui/src/vue/components/editor/useEditor.ts b/@codexteam/ui/src/vue/components/editor/useEditor.ts index 8fb7c533..005f8f49 100644 --- a/@codexteam/ui/src/vue/components/editor/useEditor.ts +++ b/@codexteam/ui/src/vue/components/editor/useEditor.ts @@ -18,6 +18,11 @@ interface UseEditorComposableState { * Attribute containing is-empty state. */ isEmpty: Ref; + + /** + * Returns the current content, including changes not yet reported by onChange + */ + save: () => Promise; } /** @@ -67,10 +72,18 @@ export function useEditor(editorConfig: MaybeRefOrGetter, options: /** * Function called on every change of the editor * @param api - EditorJS API + * @param instance - editor that reported the change */ - async function handleChange(api: API): Promise { + async function handleChange(api: API, instance: Editor): Promise { const data = await api.saver.save(); + /** + * Editor.js reports changes with a delay, drop the ones from an editor that has been destroyed meanwhile + */ + if (instance !== editor) { + return; + } + /** * Update the isEmpty attribute */ @@ -102,14 +115,18 @@ export function useEditor(editorConfig: MaybeRefOrGetter, options: const config = toValue(editorConfig); try { - editor = new Editor({ + const instance: Editor = new Editor({ ...config, onChange(api: API) { - void handleChange(api); + if (instance === editor) { + void handleChange(api, instance); + } }, }); - await editor?.isReady; + editor = instance; + + await instance.isReady; } catch (e) { console.error(e); } @@ -131,5 +148,8 @@ export function useEditor(editorConfig: MaybeRefOrGetter, options: return { isEmpty, + async save() { + return await editor?.save(); + }, }; } diff --git a/@codexteam/ui/src/vue/components/row/Row.vue b/@codexteam/ui/src/vue/components/row/Row.vue index 1f84e7e8..3d5c1787 100644 --- a/@codexteam/ui/src/vue/components/row/Row.vue +++ b/@codexteam/ui/src/vue/components/row/Row.vue @@ -23,7 +23,7 @@
{{ subtitle }}
@@ -108,6 +108,10 @@ defineProps<{ gap: var(--spacing-very-x); } + &__subtitle { + color: var(--base--text-secondary); + } + &__right { display: flex; align-items: center; diff --git a/@codexteam/ui/vite.config.ts b/@codexteam/ui/vite.config.ts index eecc9051..15ba71e1 100644 --- a/@codexteam/ui/vite.config.ts +++ b/@codexteam/ui/vite.config.ts @@ -29,7 +29,8 @@ export default defineConfig({ * With cssInjectedByJsPlugin enabled: * - Component CSS is injected into vue.js bundle (no separate CSS files for components) * - Base styles are emitted as dist/style.css - * - Fonts are emitted as dist/styles/fonts.css (optional import) + * - Fonts are copied from public/ as dist/styles/fonts.css and dist/fonts/* (optional import), + * lib mode would inline them as base64 otherwise * - Theme CSS files are emitted as dist/styles/themes/*.css * This allows users to import only the themes they need: * - import '@codexteam/ui/styles' (base styles) @@ -44,13 +45,6 @@ export default defineConfig({ */ style: resolve(__dirname, 'src/styles/index.pcss'), - /** - * Optional fonts (Inter + JetBrains Mono) - * Exported as: @codexteam/ui/styles/fonts - * Vite will automatically copy font files to dist/fonts/ when processing @import url() - */ - 'styles/fonts': resolve(__dirname, 'src/styles/fonts.pcss'), - /** * Individual themes for tree-shaking */ diff --git a/nginx/default.conf b/nginx/default.conf index 5f333493..6897e8a0 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -1,7 +1,19 @@ server { listen 80; root /usr/share/nginx/html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } + location / { try_files $uri $uri/ /index.html =404; } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 4d005a77..3ff4f3e2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "nanoid": "^5.0.7", "normalize.css": "^8.0.1", "typescript-cookie": "^1.0.6", - "unhead": "^1.8.3", "vue": "^3.3.4", "vue-i18n": "^9.2.2", "vue-router": "^4.2.4" diff --git a/src/App.vue b/src/App.vue index 70751eb9..9b07e2b9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,13 +3,19 @@ + diff --git a/src/application/i18n/messages/en.json b/src/application/i18n/messages/en.json index 5c39816e..241ee65f 100644 --- a/src/application/i18n/messages/en.json +++ b/src/application/i18n/messages/en.json @@ -1,75 +1,85 @@ { "appTitle": "NoteX", "loadMore": "Load more", - "auth": { - "login": "Login", - "logout": "đź‘‹Logout" + "cancel": "Cancel", + "toast": { + "close": "Close", + "networkError": "No connection to the server. Check your internet and try again", + "failed": "Something went wrong. Try again", + "failedWithReason": "Something went wrong: {message}" }, - "header": { - "buttons": { - "noteSettings": "Settings" - } + "auth": { + "login": "Log in", + "logout": "Log out", + "continueWithGoogle": "Continue with Google" }, "userSettings": { - "title": "User Settings", - "editorTools": "Editor Tools", + "title": "Settings", + "shortTitle": "Settings", + "account": "Account", + "accountCaption": "Your name is shown to collaborators, the email is used to log in", + "editorTools": "Editor tools", + "editorToolsCaption": "Blocks available in the editor toolbox", "uninstallEditorTool": "Uninstall", - "toolUninstallConfirmation": "Do you really want to delete this tool?", - "nameCaption": "Will be displayed in collaborators list", - "emailCaption": "Used for login", - "name": "Name", - "email": "Email", - "general": "General", + "toolUninstallConfirmation": "{title} will disappear from the editor toolbox. Notes that already use it keep their content.", "visitMarketplace": { "title": "Add more tools", - "caption": "Discover new Blocks, Formattings and other tools to improve your pages", - "button": "View all tools" + "caption": "Discover new blocks and formatting tools", + "button": "Marketplace" }, "appearance": { - "title": "Appearance", "colorSheme": { - "title": "Color sheme", - "caption": "Choose your side" + "title": "Color scheme", + "light": "Light", + "dark": "Dark" }, "baseTheme": { "title": "Base theme", "caption": "Main colors of the application" }, - "accentTheme" : { + "accentTheme": { "title": "Accent theme", "caption": "Primary buttons and highlights" } } }, "noteSettings": { - "title": "Note Settings", + "title": "Note settings", "settings": "Settings", - "customHostname": "Custom Hostname", - "hostnamePlaceholder": "example: landing.codex.so", "availabilityTitle": "Availability", - "availabilityCaption": "Should the Note be available by its URL for people who knows it?", - "availabilityRowTitle": "Note is published", + "availabilityCaption": "Published notes can be read by anyone who has the link", + "availabilityRowTitle": "Published", + "availabilityPublic": "Anyone with the link can read", + "availabilityPrivate": "Only collaborators have access", "inviteCollaboratorTitle": "Invite a collaborator", - "inviteCollaboratorCaption": "Send this link to someone you want to add as an editor or reader.", + "inviteCollaboratorCaption": "Send this link to someone you want to add as an editor or reader", "revokeHashButton": "Revoke", - "revokeHashConfirmation": "Do you really want to revoke this link?", + "revokeHashTitle": "Revoke the invite link", + "revokeHashConfirmation": "The current link will stop working and a new one will be created.", "copyInviteLink": "Copy", - "teamFormFieldSetTitle": "Team", + "copied": "Copied", + "dangerZone": "Danger zone", "deleteNote": "Delete", - "noteDeleteConfirmation": "Do you really want to delete this note?", + "deleteNoteTitle": "Delete this note", + "deleteNoteCaption": "The note and its version history will be deleted for everyone. This can't be undone.", + "noteDeleteConfirmation": "The note and its version history will be deleted for everyone. This can't be undone.", "parentNote": "Parent note", - "parentNoteCaption": "Access settings will be inherited", - "setParent": "Set as parent", - "parentNotePlaceholder": "Paste a link here", + "parentNoteCaption": "Access settings will be inherited from the parent", + "parentNotePlaceholder": "Paste a link to the parent note", + "parentNoteInvalidLink": "This is not a link to a note", + "parentNoteError": "Could not set the parent. Check the link and your access to that note", + "setParent": "Set parent", "team": { "title": "Collaborators", - "caption": "Who can access the Note even if it’s not published", + "caption": "Who can access the note even if it's not published", + "you": "You", + "owner": "Owner", "roles": { "Read": "Reader", "Write": "Writer" }, - "removeMemberConfirmationTitle": "Remove member", - "removeMemberConfirmationBody": "Are you sure you want to remove '{username}' from the team?", + "removeMemberConfirmationTitle": "Remove collaborator", + "removeMemberConfirmationBody": "{username} will lose access to the note unless it is published.", "contextMenu": { "title": "More actions", "remove": "Remove" @@ -77,35 +87,47 @@ } }, "note": { - "new": "New Note", - "unlink": "Unlink parent", + "new": "New note", + "unlink": "Unlink", "untitled": "Untitled", "open": "Open", - "createChildNote": "Create child note", + "createChildNote": "Create a child note", + "breadcrumbs": "Parent notes", + "readOnly": "Read only", "editor": { - "placeholder": "Write something or press / to select a tool" + "placeholder": "Write something or press / to select a tool", + "titlePlaceholder": "Title" }, - "lastEdit": "Last edit" + "status": { + "saving": "Saving…", + "saved": "Saved", + "edited": "Edited {time}", + "error": "Not saved. Retry" + } }, "history": { - "title": "Versions history", - "view": "View", - "useVersion": "Use this version", - "confirmVersionRestore": "Do you really want to use this version?", - "editedTime": "edited on" + "title": "Version history", + "button": "History", + "version": "Version", + "latest": "Latest", + "empty": "No versions yet", + "editedBy": "{name}, {time}", + "useVersion": "Restore this version", + "restore": "Restore", + "restoreError": "Could not restore the version", + "confirmVersionRestore": "The note content will be replaced with this version. The current content stays in the history." }, "noteList": { - "emptyNoteList": "No Notes yet. Make your first note", - "noteListItem": { - "untitled": "Untitled" + "empty": { + "recents": "No notes yet", + "myNotes": "You haven't created any notes yet", + "caption": "Notes you create or open will appear here" } }, "home": { - "createNewNote": { - "title": "Create new note", - "caption": "Knowledge storage and presentation", - "button": "New" - }, + "title": "Home", + "notes": "Notes", + "updated": "Updated", "sections": { "recents": { "title": "Recents" @@ -114,77 +136,87 @@ "title": "My notes" } }, - "navigation": "Navigation", - "updated": "Updated", - "title": "Home", - "authText": "You are not logged in, log in to see your recent notes" + "hero": { + "title": "Notes that grow into a knowledge base", + "text": "Write with blocks, nest notes into trees and share them with your team" + } }, "error": { "401": "Unauthorized", + "403": "No access", "404": "Not found", - "403": "Forbidden", - "500": "Internal server error" + "500": "Something went wrong" }, "errors": { - "401": "You must be authenticated to access this resource", - "404": "Page not found", - "403": "Access Denied", - "500": "Unknown error happened", - "default": "Something went wrong" + "401": "Log in to see this page", + "403": "You don't have access to this page. Ask its owner for an invite link", + "404": "The page doesn't exist or has been deleted", + "500": "An unexpected error happened. Try again later", + "default": "Something went wrong", + "goHome": "Go to home page" }, - "authorize" : { - "message" : "Authorization required" + "authorize": { + "title": "Log in to continue", + "message": "This page is available to logged in users only" }, "join": { - "title": "Joining a note team...", - "messages": { - "linkExpired": "This join link has been expired", - "validationError": "Wrong invitation hash specified", - "unauthorized": "You must be authenticated to access this resource" + "joining": { + "title": "Joining the note…", + "text": "You will be redirected in a moment" + }, + "expired": { + "title": "The invite link has expired", + "text": "Ask the note owner for a new link" + }, + "invalid": { + "title": "The invite link is invalid", + "text": "Check that the link was copied completely" + }, + "failed": { + "title": "Could not join the note", + "text": "Try again later or ask the note owner for a new link" } }, "marketplace": { "title": "Marketplace", "listOfTools": "Tools", - "subtitle": "Achieve any goal on your page", + "subtitle": "Blocks and tools for the editor", + "default": "Built-in", "uninstallTool": "Uninstall", "installTool": "Install", - "addTool": "Add Tool", - "userPerspective": "User perspective", + "addTool": "Add a tool", + "addToolCaption": "Publish your Editor.js tool so others can install it", + "userPerspective": "What users see", "technicalDetails": "Technical details", - "selectFile": "Select file", "newTool": { "title": { "placeholder": "Payment Widget", "label": "Title", - "caption":"Will be visible in Editor Toolbox" + "caption": "Will be visible in the editor toolbox", + "error": "Enter a title" + }, + "description": { + "placeholder": "Add a payment button using Stripe", + "label": "Description", + "caption": "Will be visible in the tools list" }, "name": { "placeholder": "payment", "label": "Name", - "caption":"Will be used as a block type in saved data" - - }, - "picture": { - "label": "Picture", - "description": "A cool descriptive screenshot of the Tool's UI" + "caption": "Will be used as a block type in saved data", + "error": "Use latin letters, digits, - and _, starting with a letter" }, "exportName": { "placeholder": "PaymentTool", - "label": "Export Name", - "caption":"Global (window) variable that the tool exports in its UMD script" - + "label": "Export name", + "caption": "Global (window) variable that the tool exports in its UMD script", + "error": "Enter a valid JavaScript variable name" }, "cdn": { "placeholder": "https:\/\/cdn.jsdelivr.net\/npm\/{'@'}editorjs/payment{'@'}2.3.0\/dist\/payment.umd.min.js", "label": "CDN", - "caption":"Direct link to the tool’s UMD bundle" - - }, - "description": { - "placeholder": "Add a payment button using Stripe", - "label": "Description", - "caption": "Will be visible in tools list" + "caption": "Direct HTTPS link to the tool's UMD bundle", + "error": "Enter an HTTPS link to the script" }, "add": "Add tool" } @@ -192,16 +224,17 @@ "pages": { "note": "Note", "newNote": "New note", - "landing": "landing", - "userSettings": "User settings", + "landing": "Landing", + "userSettings": "Settings", "noteSettings": "Note settings", "marketplace": "Marketplace", - "addTool": "Add tool", - "error": "Oops! Something went wrong", + "addTool": "Add a tool", + "error": "Something went wrong", "notFound": "Not found", "joinTeam": "Join", - "authorization": "Authorize", + "authorization": "Log in", "history": "History", - "historyVersion": "Version" + "historyVersion": "Version", + "home": "Home" } } diff --git a/src/application/router/index.ts b/src/application/router/index.ts index 347f1e5c..52b2c19a 100644 --- a/src/application/router/index.ts +++ b/src/application/router/index.ts @@ -1,9 +1,16 @@ import { createRouter, createWebHistory } from 'vue-router'; -import routes from '@/application/router/routes'; +import routes, { Note } from '@/application/router/routes'; const router = createRouter({ history: createWebHistory(), routes, }); +/** + * Most visits end on a note, so its chunk with the editor is fetched once the first page is shown + */ +const PREFETCH_DELAY = 2000; + +void router.isReady().then(() => setTimeout(() => void Note(), PREFETCH_DELAY)); + export default router; diff --git a/src/application/router/routes.ts b/src/application/router/routes.ts index 90c84ede..5b8b7779 100644 --- a/src/application/router/routes.ts +++ b/src/application/router/routes.ts @@ -1,12 +1,16 @@ import Home from '@/presentation/pages/Home.vue'; -import Note from '@/presentation/pages/Note.vue'; import Landing from '@/presentation/pages/Landing.vue'; -import type { RouteRecordRaw } from 'vue-router'; +import type { RouteComponent, RouteRecordRaw } from 'vue-router'; import MarketplaceTools from '@/presentation/pages/marketplace/MarketplaceTools.vue'; // Default production hostname for homepage. If different, then custom hostname used const websiteHostname = import.meta.env.VITE_PRODUCTION_HOSTNAME; +/** + * Note page with the editor is the heaviest one, it is loaded separately and prefetched once the app is idle + */ +export const Note = async (): Promise<{ default: RouteComponent }> => await import('@/presentation/pages/Note.vue'); + const routes: RouteRecordRaw[] = [ { name: 'home', @@ -29,7 +33,6 @@ const routes: RouteRecordRaw[] = [ path: '/note/:id', component: Note, meta: { - layout: 'fullpage', pageTitleI18n: 'pages.note', }, props: route => ({ @@ -41,7 +44,6 @@ const routes: RouteRecordRaw[] = [ path: '/note/:noteId/history', component: () => import('@/presentation/pages/History.vue'), meta: { - layout: 'fullpage', pageTitleI18n: 'pages.history', authRequired: true, }, @@ -54,7 +56,6 @@ const routes: RouteRecordRaw[] = [ path: '/note/:noteId/history/:historyId', component: () => import('@/presentation/pages/HistoryVersion.vue'), meta: { - layout: 'fullpage', pageTitleI18n: 'pages.historyVersion', authRequired: true, }, @@ -72,9 +73,7 @@ const routes: RouteRecordRaw[] = [ }, meta: { pageTitleI18n: 'pages.newNote', - discardTabOnLeave: true, authRequired: true, - layout: 'fullpage', }, }, { @@ -86,7 +85,6 @@ const routes: RouteRecordRaw[] = [ }), meta: { pageTitleI18n: 'pages.newNote', - discardTabOnLeave: true, authRequired: true, }, }, @@ -154,7 +152,6 @@ const routes: RouteRecordRaw[] = [ }), meta: { pageTitleI18n: 'pages.joinTeam', - discardTabOnLeave: true, authRequired: true, }, }, @@ -163,11 +160,10 @@ const routes: RouteRecordRaw[] = [ path: '/auth', component: () => import('@/presentation/pages/AuthorizationPage.vue'), props: route => ({ - redirect: String(route.query.redirect), + redirect: typeof route.query.redirect === 'string' ? route.query.redirect : undefined, }), meta: { pageTitleI18n: 'pages.authorization', - discardTabOnLeave: true, }, }, /** @@ -177,9 +173,7 @@ const routes: RouteRecordRaw[] = [ path: '/:pathMatch(.*)*', component: () => import('@/presentation/pages/Error.vue'), meta: { - layout: 'fullpage', pageTitleI18n: 'pages.notFound', - discardTabOnLeave: true, }, props: { code: 404, @@ -192,9 +186,7 @@ const routes: RouteRecordRaw[] = [ path: '/error/:code', component: () => import('@/presentation/pages/Error.vue'), meta: { - layout: 'fullpage', pageTitleI18n: 'pages.error', - discardTabOnLeave: true, }, props: route => ({ code: route.params.code, diff --git a/src/application/services/useAppState.ts b/src/application/services/useAppState.ts index b0403cbd..ecdce7ca 100644 --- a/src/application/services/useAppState.ts +++ b/src/application/services/useAppState.ts @@ -1,8 +1,8 @@ -import { AppStateController } from '@/domain'; +import { AppStateController, editorToolsService } from '@/domain'; import type EditorTool from '@/domain/entities/EditorTool'; import type { User } from '@/domain/entities/User'; import { createSharedComposable } from '@vueuse/core'; -import { type Ref, ref } from 'vue'; +import { type Ref, ref, watch } from 'vue'; /** * Composable for the application state @@ -48,6 +48,15 @@ export const useAppState = createSharedComposable((): UseAppStateComposable => { } }); + /** + * Download user tools in the background, so the first opened note does not wait for them + */ + watch(userEditorTools, (tools) => { + if (tools !== undefined) { + void editorToolsService.getToolsLoaded(tools); + } + }); + return { user, userEditorTools, diff --git a/src/application/services/useAuth.ts b/src/application/services/useAuth.ts index 1d924146..e1d983d5 100644 --- a/src/application/services/useAuth.ts +++ b/src/application/services/useAuth.ts @@ -35,18 +35,25 @@ export default function useAuth(): UseOAuthComposableState { /** * Open popup at the center of the screen */ - const popupWidth = 600; - const popupHeight = 400; - const left = (window.screen.width - popupWidth) / 2; - const top = (window.screen.height - popupHeight) / 2; + const popupWidth = 500; + const popupHeight = 600; + const left = window.screenX + (window.outerWidth - popupWidth) / 2; + const top = window.screenY + (window.outerHeight - popupHeight) / 2; - window.open(loginUrl, 'oauth', `popup=true, width=600, height=400, left=${left}, top=${top}`); + window.open(loginUrl, 'oauth', `popup=true, width=${popupWidth}, height=${popupHeight}, left=${left}, top=${top}`); if (callbackId !== null) { off(callbackId); } callbackId = on((event) => { + /** + * Accept tokens only from the OAuth callback page served by the API + */ + if (event.origin !== new URL(loginUrl).origin || typeof event.data !== 'object' || event.data === null) { + return; + } + if ('accessToken' in event.data && 'refreshToken' in event.data) { authService.acceptSession(event.data.accessToken, event.data.refreshToken); diff --git a/src/application/services/useAuthRequired.ts b/src/application/services/useAuthRequired.ts index 33a0d544..7a1ea463 100644 --- a/src/application/services/useAuthRequired.ts +++ b/src/application/services/useAuthRequired.ts @@ -1,47 +1,37 @@ import { useAppState } from './useAppState.ts'; -import useAuth from './useAuth.ts'; import { useRouter } from 'vue-router'; import { until } from '@vueuse/core'; /** * Function that is used in App for checking user authorization * Works only for routes with authRequired set to true in route.meta - * If user is not authorized will show auth popup + * If user is not authorized, redirects to the authorization page */ export default function useAuthRequired(): void { - const { showGoogleAuthPopup } = useAuth(); const router = useRouter(); const { user } = useAppState(); /** - * Check if user is authorized - * When oauth will work, it will be treated as he authorized manually - * @returns true if user is authorized, false otherwise + * For each route check if auth is required + * The login popup is opened from the authorization page by a click, browsers block popups opened without it */ - async function isUserAuthorized(): Promise { + router.beforeEach(async (to) => { + if (to.meta.authRequired !== true) { + return true; + } + /** * Wait until authorization process is finished */ await until(user).not.toBe(undefined); - return user.value !== null; - } - - /** - * For each route check if auth is required - */ - router.beforeEach(async (actualRoute, _, next) => { - const isAuthorized = await isUserAuthorized(); - - if (actualRoute.meta.authRequired === true && !isAuthorized) { - /** - * If auth is required and user is not autorized - * Then show google auth popup and redirect user to auth page - */ - showGoogleAuthPopup(); - next(`/auth?redirect=${actualRoute.fullPath?.toString()}`); - } else { - next(); + if (user.value === null) { + return { + name: 'authorization', + query: { redirect: to.fullPath }, + }; } + + return true; }); } diff --git a/src/application/services/useNavbar.ts b/src/application/services/useNavbar.ts index 1ecc8bda..8172bc05 100644 --- a/src/application/services/useNavbar.ts +++ b/src/application/services/useNavbar.ts @@ -1,19 +1,13 @@ import type { ComputedRef } from 'vue'; -import { computed, ref } from 'vue'; -import { useRouter, useRoute } from 'vue-router'; +import { computed, onScopeDispose, ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { createSharedComposable } from '@vueuse/core'; import { AppStateController } from '@/domain'; import type { OpenedPage } from '@/domain/entities/OpenedPage'; import { workspaceService } from '@/domain/index'; import { useI18n } from 'vue-i18n'; -import { notEmpty } from '@/infrastructure/utils/empty'; interface useNavbarComposableState { - /** - * Function for adding record to opened pages storage when user opens new page - * @param page - page that had beed opened by user - */ - addOpenedPage: (page: OpenedPage) => void; - /** * Function for deleting record about opened page, when user closes page * @param url - url of closed page @@ -33,30 +27,38 @@ interface useNavbarComposableState { */ deleteOpenedPages: () => void; + /** + * Delete opened pages whose url starts with the prefix, e.g. all pages of a deleted note + * @param prefix - url prefix + */ + deleteOpenedPagesByPrefix: (prefix: string) => void; + /** * There would be stored all currently opened pages */ currentOpenedPages: ComputedRef; }; +/** + * Only notes are opened in tabs + */ +const NOTE_PAGE = /^\/note\/[\w-]+$/; + +/** + * Older tabs are closed when there are more + */ +const MAX_TABS = 10; + /** * Function for composing data for Navbar + * Shared, so the router hook and the store subscription are registered once for the whole app * @returns data used in Navbar and functions for composing data used in Navbar */ -export default function useNavbar(): useNavbarComposableState { +export default createSharedComposable((): useNavbarComposableState => { const router = useRouter(); - const route = useRoute(); const { t } = useI18n(); - const openedPages = ref(null); - - /** - * Function for adding record to opened pages storage when user opens new page - * @param page - page that had beed opened by user - */ - function addOpenedPage(page: OpenedPage): void { - workspaceService.addOpenedPage(page); - }; + const openedPages = ref([]); /** * Function for deleting record about opened page, when user closes page @@ -68,7 +70,6 @@ export default function useNavbar(): useNavbarComposableState { /** * Function for updating title of the opened page when user updated it - * e.g. user updated note's first text block, page title should be patched * @param url - url of the page, that should be updated * @param page - new data for opened page with certain url */ @@ -83,60 +84,53 @@ export default function useNavbar(): useNavbarComposableState { workspaceService.deleteOpenedPages(); } + /** + * Delete opened pages whose url starts with the prefix + * @param prefix - url prefix + */ + function deleteOpenedPagesByPrefix(prefix: string): void { + openedPages.value + .filter(page => page.url === prefix || page.url.startsWith(`${prefix}/`)) + .forEach(page => deleteOpenedPageByUrl(page.url)); + } + /** * Hook for adding new page to storage when user changes route */ - router.beforeResolve((currentRoute, prevRoute) => { - /** - * If we are created new note we should replace 'New Note' tab with tab with actual note title - */ - if (prevRoute.meta.discardTabOnLeave === true) { - deleteOpenedPageByUrl(route.path); + const removeRouterHook = router.beforeResolve((currentRoute) => { + if (!NOTE_PAGE.test(currentRoute.path)) { + return; } - /** - * If the route is '/' do not add the page - */ - if (currentRoute.path !== '/') { - addOpenedPage({ title: t(currentRoute.meta.pageTitleI18n), - url: currentRoute.path }); - } + workspaceService.addOpenedPage({ + title: t(currentRoute.meta.pageTitleI18n), + url: currentRoute.path, + }); + + const notePages = openedPages.value.filter(page => NOTE_PAGE.test(page.url)); + + [ + ...openedPages.value.filter(page => !NOTE_PAGE.test(page.url)), + ...notePages.slice(0, Math.max(0, notePages.length - MAX_TABS)), + ].forEach(page => deleteOpenedPageByUrl(page.url)); }); + onScopeDispose(removeRouterHook); + /** * Subscribe to page changes in the use Navbar */ AppStateController.openedPages((prop: 'openedPages', value: OpenedPage[] | null) => { if (prop === 'openedPages') { - openedPages.value = value as OpenedPage[]; + openedPages.value = value ?? []; } }); - /** - * Home page is always opened - */ - const currentOpenedPages = computed(() => { - const activePages = []; - - const pages = openedPages.value?.map((page) => { - return { - title: page.title, - url: page.url, - }; - }); - - if (notEmpty(pages)) { - activePages.push(...pages); - } - - return activePages; - }); - return { - addOpenedPage, deleteOpenedPageByUrl, patchOpenedPageByUrl, - currentOpenedPages, + currentOpenedPages: computed(() => openedPages.value), deleteOpenedPages, + deleteOpenedPagesByPrefix, }; -} +}); diff --git a/src/application/services/useNote.ts b/src/application/services/useNote.ts index 75ba92d5..99345fa0 100644 --- a/src/application/services/useNote.ts +++ b/src/application/services/useNote.ts @@ -1,4 +1,5 @@ -import { onMounted, ref, type Ref, type MaybeRefOrGetter, computed, toValue, watch } from 'vue'; +import { onBeforeUnmount, onMounted, ref, type Ref, type MaybeRefOrGetter, computed, toValue, watch } from 'vue'; +import { useEventListener } from '@vueuse/core'; import { noteService, editorToolsService } from '@/domain'; import type { Note, NoteContent, NoteId } from '@/domain/entities/Note'; import type { NoteTool } from '@/domain/entities/Note'; @@ -6,10 +7,23 @@ import { useRouter, useRoute } from 'vue-router'; import type { NoteDraft } from '@/domain/entities/NoteDraft'; import type EditorTool from '@/domain/entities/EditorTool'; import DomainError from '@/domain/entities/errors/Base'; +import UnauthorizedError from '@/domain/entities/errors/Unauthorized'; +import ForbiddenError from '@/domain/entities/errors/Forbidden'; import useNavbar from './useNavbar'; +import { useAppState } from './useAppState'; import { getTitle } from '@/infrastructure/utils/note'; import type { NoteHierarchy } from '@/domain/entities/NoteHierarchy'; +/** + * Pause in typing after which changes are saved + */ +const SAVE_DELAY = 1000; + +/** + * Changes are saved at least this often during continuous typing + */ +const SAVE_MAX_WAIT = 5000; + /** * Creates base structure for the empty note: * First block is Header, second is an empty Paragraph @@ -36,6 +50,28 @@ function createDraft(): NoteDraft { }; } +/** + * State of the note saving + * pending - there are unsaved changes waiting for the save delay + */ +export type NoteSaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + +/** + * Note that is being created. Gets an id after the first save + */ +interface DraftTarget { + id: NoteId | null; +} + +/** + * Changes to save, bound to the note they were made in + */ +interface SaveJob { + target: NoteId | DraftTarget; + content: NoteContent; + parentId?: NoteId; +} + /** * Note hook state */ @@ -53,19 +89,19 @@ interface UseNoteComposableState { noteTools: Ref; /** - * Creates/updates the note + * Saves the changes after a short pause, so typing does not send a request per keystroke */ - save: (content: NoteContent, parentId: NoteId | undefined) => Promise; + scheduleSave: (content: NoteContent, parentId?: NoteId) => void; /** - * Returns list of tools used in note + * Saves scheduled changes immediately */ - resolveToolsByContent: (content: NoteContent) => NoteTool[]; + flushSave: () => Promise; /** - * Load note by custom hostname + * State of the note saving */ - resolveHostname: () => Promise; + saveStatus: Ref; /** * Unlink note from parent @@ -103,6 +139,11 @@ interface UseNoteComposableOptions { * Note identifier */ id: MaybeRefOrGetter; + + /** + * Load the tree of the related notes, needed only for the note page sidebar + */ + withHierarchy?: boolean; } /** @@ -111,6 +152,7 @@ interface UseNoteComposableOptions { */ export default function (options: UseNoteComposableOptions): UseNoteComposableState { const { patchOpenedPageByUrl, deleteOpenedPageByUrl } = useNavbar(); + const { user } = useAppState(); /** * Current note identifier */ @@ -124,7 +166,7 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt const note = ref(currentId.value === null ? createDraft() : null); /** - * Here we will store the content of the note on last save + * Here we will store the latest content of the note, even if it is not saved yet */ const lastUpdateContent = ref(null); @@ -135,19 +177,10 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt */ const noteTools = ref(currentId.value === null ? [] : undefined); - /** - * Router instance used to replace the current route with note id - */ const router = useRouter(); const route = useRoute(); - /** - * Is there any note currently saving - * Used to prevent re-load note after draft is saved - */ - const isNoteSaving = ref(false); - /** * Note Title identifier */ @@ -159,10 +192,9 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt /** * Editing rights for the currently opened note - * - * true by default + * Drafts are editable, loaded notes get the rights from the API */ - const canEdit = ref(true); + const canEdit = ref(currentId.value === null); /** * Parent note @@ -173,10 +205,9 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt /** * Note parents of the actual note - * - * Actual note by default */ const noteParents = ref([]); + /** * Note hierarchy * @@ -184,14 +215,49 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt */ const noteHierarchy = ref(null); + const saveStatus = ref('idle'); + + let draft: DraftTarget = { id: null }; + + /** + * Id the draft route is being replaced with, its content is already in the editor + */ + let createdDraftId: NoteId | null = null; + + let pendingJob: SaveJob | null = null; + + let pendingSince: number | null = null; + + /** + * Last saved blocks, so the same content is not sent twice + */ + let lastSaved: { target: SaveJob['target']; blocks: string } | null = null; + + let saveTimer: ReturnType | undefined; + + /** + * Saves run one by one, so a new note is created once and later changes update it + */ + let saveQueue: Promise = Promise.resolve(); + + /** + * Incremented on each load, so a slow response for a previously opened note is ignored + */ + let loadCounter = 0; + /** * get note hierarchy * @param id - note id */ async function getNoteHierarchy(id: NoteId): Promise { - let response = await noteService.getNoteHierarchy(id); - - noteHierarchy.value = response; + try { + noteHierarchy.value = await noteService.getNoteHierarchy(id); + } catch (error) { + /** + * The sidebar is optional, the note stays usable without it + */ + console.warn('Failed to load the note hierarchy', error); + } } /** @@ -199,22 +265,47 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt * @param id - Note identifier got from composable argument */ async function load(id: NoteId): Promise { + const loadId = ++loadCounter; + + note.value = null; + lastUpdateContent.value = null; + noteTools.value = undefined; + try { const response = await noteService.getNoteById(id); + if (loadId !== loadCounter) { + return; + } + note.value = response.note; canEdit.value = response.accessRights.canEdit; noteTools.value = response.tools; parentNote.value = response.parentNote; noteParents.value = response.parents; - void getNoteHierarchy(id); + + if (options.withHierarchy === true) { + void getNoteHierarchy(id); + } } catch (error) { - deleteOpenedPageByUrl(route.path); - if (error instanceof DomainError) { - void router.push(`/error/${error.statusCode}`); - } else { - void router.push('/error/500'); + if (loadId !== loadCounter) { + return; } + + /** + * Private note opened by an anonymous user: log in and come back + */ + if ((error instanceof UnauthorizedError || error instanceof ForbiddenError) && !user.value) { + void router.replace({ + name: 'authorization', + query: { redirect: route.fullPath }, + }); + + return; + } + + deleteOpenedPageByUrl(route.path); + void router.replace(`/error/${error instanceof DomainError && error.statusCode !== undefined ? error.statusCode : '500'}`); } } @@ -242,69 +333,113 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt } /** - * Saves the note - * @param content - Note content (Editor.js data) - * @param parentId - Id of the parent note. If null, then it's a root note + * Sends the changes to the API + * @param job - changes and the note they belong to */ - async function save(content: NoteContent, parentId: NoteId | undefined): Promise { - if (note.value === null) { - throw new Error('Note is not loaded yet'); - } + async function persist(job: SaveJob): Promise { + const tools = resolveToolsByContent(job.content); - /** - * Resolve tools that are used in note - */ - const specifiedNoteTools = resolveToolsByContent(content); + saveStatus.value = 'saving'; - isNoteSaving.value = true; + try { + if (typeof job.target === 'string') { + await noteService.updateNoteContentAndTools(job.target, job.content, tools); + } else if (job.target.id !== null) { + await noteService.updateNoteContentAndTools(job.target.id, job.content, tools); + } else { + const noteCreated = await noteService.createNote(job.content, tools, job.parentId); + + job.target.id = noteCreated.id; + + /** + * Replace the draft route with the note route if user is still on the draft + */ + if (job.target === draft && currentId.value === null) { + const draftUrl = route.path; + + createdDraftId = noteCreated.id; + await router.replace({ + name: 'note', + params: { + id: noteCreated.id, + }, + }); + + deleteOpenedPageByUrl(draftUrl); + patchOpenedPageByUrl(route.path, { + title: getTitle(job.content), + url: route.path, + }); + + if (options.withHierarchy === true) { + void getNoteHierarchy(noteCreated.id); + } + } + } - if (currentId.value === null) { - /** - * @todo try-catch domain errors - */ - const noteCreated = await noteService.createNote(content, specifiedNoteTools, parentId); + lastSaved = { + target: job.target, + blocks: JSON.stringify(job.content.blocks), + }; + saveStatus.value = pendingJob === null ? 'saved' : 'pending'; + } catch (error) { + console.error(error); /** - * Replace the current route with note id + * Keep the changes, so the next edit or retry saves them */ - await router.replace({ - name: 'note', - params: { - id: noteCreated.id, - }, - }); + pendingJob ??= job; + saveStatus.value = 'error'; + } + } - patchOpenedPageByUrl( - route.path, - { - title: noteTitle.value, - url: route.path, - }); + /** + * Saves scheduled changes immediately + */ + function flushSave(): Promise { + clearTimeout(saveTimer); + pendingSince = null; - /** - * Get note Hierarchy when new Note is created - */ - void getNoteHierarchy(noteCreated.id); - } else { - await noteService.updateNoteContentAndTools(currentId.value, content, specifiedNoteTools); + const job = pendingJob; + + pendingJob = null; + + if (job !== null) { + saveQueue = saveQueue.then(() => persist(job)); } - /** - * Store just saved content in memory - */ + return saveQueue; + } + + /** + * Saves the changes after a short pause + * @param content - Note content (Editor.js data) + * @param parentId - Id of the parent note for a new note + */ + function scheduleSave(content: NoteContent, parentId?: NoteId): void { + const target = currentId.value ?? draft; + + if (pendingJob === null && lastSaved?.target === target && lastSaved.blocks === JSON.stringify(content.blocks)) { + return; + } + + pendingJob = { + target, + content, + parentId, + }; lastUpdateContent.value = content; + saveStatus.value = 'pending'; - isNoteSaving.value = false; + pendingSince ??= Date.now(); + clearTimeout(saveTimer); + saveTimer = setTimeout(() => void flushSave(), Math.min(SAVE_DELAY, pendingSince + SAVE_MAX_WAIT - Date.now())); } /** * Unlink note from parent */ async function unlinkParent(): Promise { - if (note.value === null) { - throw new Error('Note is not loaded yet'); - } - if (currentId.value === null) { throw new Error('Note id is not defined'); } @@ -314,31 +449,40 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt parentNote.value = undefined; } - /** - * Get note by custom hostname - */ - const resolveHostname = async (): Promise => { - note.value = (await noteService.getNoteByHostname(location.hostname)).note; - }; - onMounted(() => { - /** - * If we have id, load note and note hierarchy - */ if (currentId.value !== null) { void load(currentId.value); } }); + onBeforeUnmount(() => { + void flushSave(); + }); + + /** + * Ask the browser to confirm leaving the page while changes are not saved yet + */ + useEventListener(window, 'beforeunload', (event: BeforeUnloadEvent) => { + if (pendingJob !== null || saveStatus.value === 'saving') { + void flushSave(); + event.preventDefault(); + } + }); + /** * Reset note to the initial state */ function resetNote(): void { + loadCounter++; + draft = { id: null }; note.value = createDraft(); noteTools.value = []; canEdit.value = true; lastUpdateContent.value = null; noteHierarchy.value = null; + noteParents.value = []; + parentNote.value = undefined; + saveStatus.value = 'idle'; } /** @@ -347,31 +491,25 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt * @param title - The new title to update in the hierarchy */ function updateNoteHierarchyContent(hierarchy: NoteHierarchy | null, title: string): void { - // If hierarchy is null, there's nothing to update if (!hierarchy) { return; } - // If content is null, we can't update the hierarchy content - if (!title) { - return; - } - - // Update the title of the current note in the hierarchy if it matches the currentId if (hierarchy.noteId === currentId.value) { hierarchy.noteTitle = title; } - // Recursively update child notes - if (hierarchy.childNotes) { - hierarchy.childNotes.forEach(child => updateNoteHierarchyContent(child, title)); - } + hierarchy.childNotes?.forEach(child => updateNoteHierarchyContent(child, title)); } - watch(currentId, (newId, prevId) => { + watch(currentId, (newId) => { + /** + * Changes of the previous note are bound to it, save them before switching + */ + void flushSave(); + /** * One note is open, user clicks on "+" to create another new note - * Clear existing note */ if (newId === null) { resetNote(); @@ -379,21 +517,18 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt return; } - const isDraftSaving = prevId === null && isNoteSaving.value; + if (newId === createdDraftId) { + createdDraftId = null; - /** - * Case for newly created note, - * we don't need to re-load it - */ - if (isDraftSaving) { return; } + saveStatus.value = 'idle'; void load(newId); }); watch(noteTitle, (currentNoteTitle) => { - if (route.name == 'note') { + if (route.name === 'note' && note.value !== null) { patchOpenedPageByUrl( route.path, { @@ -409,9 +544,9 @@ export default function (options: UseNoteComposableOptions): UseNoteComposableSt noteTools, noteTitle, canEdit, - resolveHostname, - resolveToolsByContent, - save, + scheduleSave, + flushSave, + saveStatus, unlinkParent, noteParents, parentNote, diff --git a/src/application/services/useNoteEditor.ts b/src/application/services/useNoteEditor.ts index a5fb4abc..c9254847 100644 --- a/src/application/services/useNoteEditor.ts +++ b/src/application/services/useNoteEditor.ts @@ -1,4 +1,4 @@ -import { type Ref, computed, ref, toValue, watch } from 'vue'; +import { type MaybeRefOrGetter, type Ref, computed, ref, toValue, watch } from 'vue'; import { useAppState } from './useAppState'; import type EditorTool from '@/domain/entities/EditorTool'; import { type NoteContent } from '@/domain/entities/Note'; @@ -21,6 +21,11 @@ interface UseNoteEditorOptions { * Flag indicating that user can edit the note */ canEdit: Ref; + + /** + * Put the caret into the editor on load, e.g. for a new note + */ + autofocus?: MaybeRefOrGetter; } interface UseNoteEditorComposableState { @@ -147,9 +152,11 @@ export const useNoteEditor = function useNoteEditor(options: UseNoteEditorOption */ watch(noteAndUserTools, async (tools) => { /** - * If tools are not loaded yet, wait for the next change + * If tools are not loaded yet, e.g. another note is loading, hide the editor and wait for the next change */ if (tools === undefined) { + isEditorReady.value = false; + return; } @@ -196,6 +203,8 @@ export const useNoteEditor = function useNoteEditor(options: UseNoteEditorOption readOnly: toValue(options.canEdit) === false, tools: toolsUserConfigLoaded.value ? toolsUserConfig : undefined, placeholder: t('note.editor.placeholder'), + firstBlockPlaceholder: t('note.editor.titlePlaceholder'), + autofocus: toValue(options.autofocus) === true, inlineToolbar: true, }; }); diff --git a/src/application/services/useNoteSettings.ts b/src/application/services/useNoteSettings.ts index bd488b3a..96017b1f 100644 --- a/src/application/services/useNoteSettings.ts +++ b/src/application/services/useNoteSettings.ts @@ -4,7 +4,6 @@ import type { Note, NoteId } from '@/domain/entities/Note'; import { noteSettingsService, noteService } from '@/domain'; import type { UserId } from '@/domain/entities/User'; import type { MemberRole } from '@/domain/entities/Team'; -import { useRouter } from 'vue-router'; /** * Note settings hook state @@ -15,11 +14,6 @@ interface UseNoteSettingsComposableState { */ noteSettings: Ref; - /** - * Parent note, undefined if it's a root note - */ - parentNote: Ref; - /** * Load note settings * @param id - note id @@ -64,8 +58,9 @@ interface UseNoteSettingsComposableState { * Set parent for the note * @param id - Child note id * @param newParentURL - New parent note URL + * @returns the new parent note */ - setParent: (id: NoteId, newParentURL: string) => Promise; + setParent: (id: NoteId, newParentURL: string) => Promise; /** * Delete team member by user id @@ -85,27 +80,12 @@ export default function (): UseNoteSettingsComposableState { */ const noteSettings = ref(null); - /** - * Parent note - * - * undefined by default - */ - const parentNote = ref(); - - /** - * Router instance used to replace the current route with note id - */ - const router = useRouter(); - /** * Get note settings * @param id - Note id */ const load = async (id: NoteId): Promise => { noteSettings.value = await noteSettingsService.getNoteSettingsById(id); - const response = await noteService.getNoteById(id); - - parentNote.value = response.parentNote; }; /** @@ -114,13 +94,23 @@ export default function (): UseNoteSettingsComposableState { * @param newIsPublicValue - new isPublic */ async function updateIsPublic(id: NoteId, newIsPublicValue: boolean): Promise { - const { isPublic } = await noteSettingsService.patchNoteSettingsByNoteId(id, { isPublic: newIsPublicValue }); + const settings = noteSettings.value; /** - * If note settings were not loaded till this moment for some reason, do nothing + * Switch right away, revert if the request fails */ - if (noteSettings.value) { - noteSettings.value.isPublic = isPublic; + if (settings) { + settings.isPublic = newIsPublicValue; + } + + try { + await noteSettingsService.patchNoteSettingsByNoteId(id, { isPublic: newIsPublicValue }); + } catch (error) { + if (settings) { + settings.isPublic = !newIsPublicValue; + } + + throw error; } } @@ -159,10 +149,6 @@ export default function (): UseNoteSettingsComposableState { */ const deleteNoteById = async (id: NoteId): Promise => { await noteSettingsService.deleteNote(id); - - void router.push({ - name: 'home', - }); }; /** @@ -170,14 +156,8 @@ export default function (): UseNoteSettingsComposableState { * @param id - Child note id * @param newParentURL - New parent note URL */ - async function setParent(id: NoteId, newParentURL: string): Promise { - try { - parentNote.value = await noteService.setParentByUrl(id, newParentURL); - } catch (error) { - if (error instanceof Error) { - window.alert(error.message); - } - } + async function setParent(id: NoteId, newParentURL: string): Promise { + return await noteService.setParentByUrl(id, newParentURL); }; /** @@ -209,7 +189,6 @@ export default function (): UseNoteSettingsComposableState { return { updateCover, setParent, - parentNote, noteSettings, load, updateIsPublic, diff --git a/src/application/services/usePageTitle.ts b/src/application/services/usePageTitle.ts new file mode 100644 index 00000000..30740ccb --- /dev/null +++ b/src/application/services/usePageTitle.ts @@ -0,0 +1,10 @@ +import { computed, type MaybeRefOrGetter, toValue } from 'vue'; +import { useTitle } from '@vueuse/core'; + +/** + * Sets the browser tab title of the page + * @param title - page title + */ +export default function usePageTitle(title: MaybeRefOrGetter): void { + useTitle(computed(() => toValue(title)), { titleTemplate: '%s · NoteX' }); +} diff --git a/src/application/services/useToast.ts b/src/application/services/useToast.ts new file mode 100644 index 00000000..172b074b --- /dev/null +++ b/src/application/services/useToast.ts @@ -0,0 +1,72 @@ +import { createSharedComposable } from '@vueuse/core'; +import { ref, type Ref } from 'vue'; + +/** + * Short message shown at the bottom of the screen + */ +export interface Toast { + id: number; + text: string; +} + +interface UseToastComposableState { + /** + * Messages currently shown + */ + toasts: Ref; + + /** + * Shows the message for a few seconds + * @param text - message + */ + showToast: (text: string) => void; + + /** + * Hides the message + * @param id - message id + */ + hideToast: (id: number) => void; +} + +/** + * How long a message is shown + */ +const TOAST_DURATION = 6000; + +/** + * At most this many messages are shown at once + */ +const MAX_TOASTS = 3; + +export default createSharedComposable((): UseToastComposableState => { + const toasts = ref([]); + + let lastId = 0; + + function hideToast(id: number): void { + toasts.value = toasts.value.filter(toast => toast.id !== id); + } + + function showToast(text: string): void { + if (toasts.value.some(toast => toast.text === text)) { + return; + } + + const id = ++lastId; + + toasts.value = [ + ...toasts.value.slice(1 - MAX_TOASTS), + { + id, + text, + }, + ]; + setTimeout(() => hideToast(id), TOAST_DURATION); + } + + return { + toasts, + showToast, + hideToast, + }; +}); diff --git a/src/index.ts b/src/index.ts index fbf6579b..ea8cb4cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import { createApp } from 'vue'; -import { createHead } from 'unhead'; import App from './App.vue'; import i18n from '@/application/i18n'; import hawk from '@/application/error-catcher'; @@ -18,7 +17,6 @@ import '@/presentation/styles/index.pcss'; const app = createApp(App); app.use(hawk); -app.use(createHead); app.use(router); app.use(i18n); app.mount('#app'); diff --git a/src/infrastructure/auth.repository.ts b/src/infrastructure/auth.repository.ts index ac058609..3780137c 100644 --- a/src/infrastructure/auth.repository.ts +++ b/src/infrastructure/auth.repository.ts @@ -36,10 +36,12 @@ export default class AuthRepository implements AuthRepositoryInterface { /** * Get new session by refresh token + * The API replaces the refresh token on each use, so browser tabs refresh one by one + * and each of them sends the token saved by the previous one */ public async restoreSession(): Promise { - return this.transport.post( - { + const refresh = async (): Promise => { + const session = await this.transport.post({ endpoint: '/auth', data: { token: this.authStorage.getRefreshToken(), @@ -47,8 +49,15 @@ export default class AuthRepository implements AuthRepositoryInterface { params: { skipAuthCheck: true, }, - } - ); + }); + + this.authStorage.setRefreshToken(session.refreshToken); + + return session; + }; + + // eslint-disable-next-line n/no-unsupported-features/node-builtins + return 'locks' in navigator ? await navigator.locks.request('notex-refresh-token', refresh) : await refresh(); } /** diff --git a/src/infrastructure/editorTools.repository.ts b/src/infrastructure/editorTools.repository.ts index 47ac8078..be083e73 100644 --- a/src/infrastructure/editorTools.repository.ts +++ b/src/infrastructure/editorTools.repository.ts @@ -29,39 +29,40 @@ export default class EditorToolsRepository /** * Get stored tools plugins, if tool not exists, download it + * Tools are downloaded in parallel, a tool that fails to load is skipped so the editor works with the rest * @param tools - request list of tools */ public async getToolsLoaded(tools: EditorTool[]): Promise { - const configTools: EditorToolLoaded[] = []; - - for (const tool of tools) { + const loadedTools = await Promise.all(tools.map(async (tool) => { const storedTool = this.store.getToolByName(tool.name); if (storedTool) { - configTools.push(storedTool); - } else { - try { - const downloadedTool = await this.transport.downloadTool(tool); + return storedTool; + } + + try { + const downloadedTool = await this.transport.downloadTool(tool); - if (downloadedTool === undefined) { - continue; - } + if (downloadedTool === undefined) { + return undefined; + } - const toolClassAndInfo = { - class: downloadedTool, - tool, - }; + const toolClassAndInfo = { + class: downloadedTool, + tool, + }; - this.store.addTool(toolClassAndInfo); + this.store.addTool(toolClassAndInfo); - configTools.push(toolClassAndInfo); - } catch (error) { - throw new Error(`Failed to download ${tool.name}.`); - } + return toolClassAndInfo; + } catch { + console.warn(`Failed to download the ${tool.name} editor tool from ${tool.source.cdn}`); + + return undefined; } - } + })); - return configTools; + return loadedTools.filter((tool): tool is EditorToolLoaded => tool !== undefined); } /** diff --git a/src/infrastructure/index.ts b/src/infrastructure/index.ts index f84ef11f..c9a094bc 100644 --- a/src/infrastructure/index.ts +++ b/src/infrastructure/index.ts @@ -135,6 +135,8 @@ export function init(noteApiUrl: string, eventBus: EventBus): Repositories { const noteRepository = new NoteRepository(noteStore, notesApiTransport); const noteSettingsRepository = new NoteSettingsRepository(notesApiTransport); const authRepository = new AuthRepository(authStore, notesApiTransport); + + notesApiTransport.setSessionRefresher(async () => (await authRepository.restoreSession()).accessToken); const userRepository = new UserRepository(userStore, notesApiTransport); const marketplaceRepository = new MarketplaceRepository(notesApiTransport); const editorToolsRepository = new EditorToolsRepository(editorToolsStore, editorToolsTransport); diff --git a/src/infrastructure/storage/openedPage.ts b/src/infrastructure/storage/openedPage.ts index 27a532eb..7f893a62 100644 --- a/src/infrastructure/storage/openedPage.ts +++ b/src/infrastructure/storage/openedPage.ts @@ -14,6 +14,18 @@ export type OpenedPagesStoreData = { export class OpenedPagesStore extends PersistantStore { constructor() { super(['openedPages']); + + /** + * Keep tabs the same in all browser windows + */ + window.addEventListener('storage', (event) => { + if (event.key === 'openedPages') { + this.onDataChange([{ + prop: 'openedPages', + newValue: this.data.openedPages, + }]); + } + }); } /** diff --git a/src/infrastructure/transport/authorizable.transport.ts b/src/infrastructure/transport/authorizable.transport.ts index f2217df6..5000cbf3 100644 --- a/src/infrastructure/transport/authorizable.transport.ts +++ b/src/infrastructure/transport/authorizable.transport.ts @@ -2,6 +2,7 @@ import Transport from '@/infrastructure/transport'; import type { FetchTransportOptions } from './fetch.transport'; import type JSONValue from './types/JSONValue'; import type { POSTParamsAuthorizable } from './types/POSTParams'; +import UnauthorizedError from '@/domain/entities/errors/Unauthorized'; /** * Additional options for authorizable transport @@ -39,6 +40,16 @@ export default class AuthorizableTransport extends Transport { */ private waitingAuthRequests: Array<() => void> = []; + /** + * Gets a new access token when the current one is rejected + */ + private sessionRefresher?: () => Promise; + + /** + * Access token request in progress, shared by all requests rejected meanwhile + */ + private refreshing: Promise | null = null; + /** * Constructor for notes api transport * @param baseUrl - Base URL @@ -64,11 +75,20 @@ export default class AuthorizableTransport extends Transport { * Continue anonymous session. All request will be made without authorization header */ public continueAnonymous(): void { + this.headers.delete('Authorization'); this.authState = 'unauthorized'; this.onAuthFinished(); } + /** + * Sets the function that gets a new access token when the current one is rejected + * @param refresher - returns a new access token + */ + public setSessionRefresher(refresher: () => Promise): void { + this.sessionRefresher = refresher; + } + /** * Gets specific resource * @param endpoint - API endpoint @@ -76,9 +96,7 @@ export default class AuthorizableTransport extends Transport { * @param params - Additional params to tune request */ public async get(endpoint: string, data?: JSONValue, params?: AuthorizableRequestParams): Promise { - await this.waitForAuth(params); - - return super.get(endpoint, data); + return this.send(params, () => super.get(endpoint, data)); } /** @@ -88,9 +106,7 @@ export default class AuthorizableTransport extends Transport { * @param params - Additional params to tune request */ public async getBlob(endpoint: string, data?: Record, params?: AuthorizableRequestParams): Promise { - await this.waitForAuth(params); - - return super.getBlob(endpoint, data); + return this.send(params, () => super.getBlob(endpoint, data)); } /** @@ -103,13 +119,11 @@ export default class AuthorizableTransport extends Transport { params, files, }: POSTParamsAuthorizable): Promise { - await this.waitForAuth(params); - - return super.post({ + return this.send(params, () => super.post({ endpoint, payload, files, - }); + })); } /** @@ -119,9 +133,7 @@ export default class AuthorizableTransport extends Transport { * @param params - Additional params to tune request */ public async delete(endpoint: string, payload?: JSONValue, params?: AuthorizableRequestParams): Promise { - await this.waitForAuth(params); - - return super.delete(endpoint, payload); + return this.send(params, () => super.delete(endpoint, payload)); } /** @@ -131,9 +143,39 @@ export default class AuthorizableTransport extends Transport { * @param params - Additional params to tune request */ public async patch(endpoint: string, payload?: JSONValue, params?: AuthorizableRequestParams): Promise { + return this.send(params, () => super.patch(endpoint, payload)); + } + + /** + * Sends the request after authorization is finished. + * If the access token has expired, gets a new one and repeats the request once + * @param params - Additional params passed to tune request + * @param request - function making the request + */ + private async send(params: AuthorizableRequestParams | undefined, request: () => Promise): Promise { await this.waitForAuth(params); - return super.patch(endpoint, payload); + try { + return await request(); + } catch (error) { + if (!(error instanceof UnauthorizedError) || this.authState !== 'authorized' || params?.skipAuthCheck === true || this.sessionRefresher === undefined) { + throw error; + } + + const refresher = this.sessionRefresher; + + this.refreshing ??= refresher().finally(() => { + this.refreshing = null; + }); + + try { + this.authorize(await this.refreshing); + } catch { + throw error; + } + + return await request(); + } } /** @@ -149,10 +191,6 @@ export default class AuthorizableTransport extends Transport { } if (this.authState === 'unknown') { - console.groupCollapsed('âś‹ Request enqueued util auth finished'); - console.trace(); - console.groupEnd(); - await new Promise((resolve) => { this.waitingAuthRequests.push(() => { resolve(undefined); @@ -167,19 +205,7 @@ export default class AuthorizableTransport extends Transport { * Sends enqueued requests */ private onAuthFinished(): void { - if (this.waitingAuthRequests.length === 0) { - return; - } - - console.groupCollapsed(`🤙 Auth finished, sending ${this.waitingAuthRequests.length} request(s) from queue...`); - - this.waitingAuthRequests.forEach((request) => { - console.trace(); - request(); - }); - - console.groupEnd(); - + this.waitingAuthRequests.forEach(request => request()); this.waitingAuthRequests = []; } } diff --git a/src/infrastructure/utils/date.ts b/src/infrastructure/utils/date.ts index a0ad3976..7d5b277e 100644 --- a/src/infrastructure/utils/date.ts +++ b/src/infrastructure/utils/date.ts @@ -1,15 +1,15 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + /** * Returns time, that has been passed from updated at timestamp * @param updatedAt - the date the note was last updated * @returns the last updated time */ export function getTimeFromNow(updatedAt: string): string { - dayjs.extend(relativeTime); - const formattedUpdatedAt = dayjs(updatedAt).fromNow(); - - return formattedUpdatedAt; + return dayjs(updatedAt).fromNow(); } /** diff --git a/src/infrastructure/utils/load-script.ts b/src/infrastructure/utils/load-script.ts index c8c19f43..5bba8ff3 100644 --- a/src/infrastructure/utils/load-script.ts +++ b/src/infrastructure/utils/load-script.ts @@ -1,14 +1,27 @@ +/** + * Scripts that are loaded or loading, so each one is added to the page once + */ +const scripts = new Map>(); + /** * Loads script by specified url * @param src - script source url */ export function loadScript(src: string): Promise { - return new Promise(function (resolve, reject) { + const loading = scripts.get(src) ?? new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.onload = resolve; - script.onerror = reject; + script.onerror = (event) => { + script.remove(); + scripts.delete(src); + reject(event); + }; document.head.appendChild(script); }); + + scripts.set(src, loading); + + return loading; } diff --git a/src/infrastructure/utils/note.ts b/src/infrastructure/utils/note.ts index ddd01bb0..1873b42e 100644 --- a/src/infrastructure/utils/note.ts +++ b/src/infrastructure/utils/note.ts @@ -1,23 +1,19 @@ import { type OutputData } from '@editorjs/editorjs'; /** - * Get the title of the note + * Get the title of the note: plain text of its first block * @param content - content of the note * @returns the title of the note */ export function getTitle(content: OutputData | undefined): string { - const limitCharsForNoteTitle = 50; - const firstNoteBlock = content?.blocks[0]; + const html: unknown = content?.blocks[0]?.data.text; - const text: string | undefined = firstNoteBlock?.data.text; - - /** - * If the heading is empty, return 'Untitled' - */ - if (text === undefined || text.trim() === '') { + if (typeof html !== 'string') { return 'Untitled'; - } else { - return text?.replace(/ /g, ' ')?.slice?.(0, limitCharsForNoteTitle); } + + const text = new DOMParser().parseFromString(html, 'text/html').body.textContent?.trim(); + + return text !== undefined && text !== '' ? text : 'Untitled'; } /** diff --git a/src/infrastructure/utils/screenshot.ts b/src/infrastructure/utils/screenshot.ts index 530b7744..18e3281c 100644 --- a/src/infrastructure/utils/screenshot.ts +++ b/src/infrastructure/utils/screenshot.ts @@ -1,8 +1,13 @@ -import html2canvas from 'html2canvas'; +/** + * Half size is enough for a list card and keeps the file around 15 KB instead of 400 KB on retina screens + */ +const COVER_SCALE = 0.5; + +const COVER_QUALITY = 0.8; /** * Make html element screenshot - * @param element - id of element, which we want to be screenshot + * @param element - element to take a screenshot of * @param containerStyles - styles for screenshot container * @returns binary image data */ @@ -16,37 +21,24 @@ export async function makeElementScreenshot(element: HTMLElement, containerStyle screenshotContainer.setAttribute('theme-base', 'graphite'); screenshotContainer.setAttribute('theme-accent', 'sky'); - /** - * Assign passed styles to container - */ - Object.assign(screenshotContainer.style, containerStyles); - - /** - * Make clone of element for screen to fill container - */ - const clonedElement = element.cloneNode(true); + Object.assign(screenshotContainer.style, containerStyles, { + position: 'absolute', + top: '-9999px', + left: '-9999px', + }); /** - * Set base styles to container, it need to be out of window + * Clone synchronously, so the screenshot shows the element as it is now even if the page changes meanwhile */ - screenshotContainer.style.position = 'absolute'; - screenshotContainer.style.top = '-9999px'; - screenshotContainer.style.left = '-9999px'; - + screenshotContainer.appendChild(element.cloneNode(true)); document.body.appendChild(screenshotContainer); - screenshotContainer.appendChild(clonedElement); - - const canvas = await html2canvas(screenshotContainer); + try { + const { default: html2canvas } = await import('html2canvas'); + const canvas = await html2canvas(screenshotContainer, { scale: COVER_SCALE }); - return new Promise((resolve) => { - canvas.toBlob((data) => { - resolve(data); - /** - * Remove element from container - */ - screenshotContainer.removeChild(clonedElement); - }, 'image/png'); + return await new Promise(resolve => canvas.toBlob(resolve, 'image/webp', COVER_QUALITY)); + } finally { + screenshotContainer.remove(); } - ); } diff --git a/src/presentation/components/app-navbar/AppNavbar.vue b/src/presentation/components/app-navbar/AppNavbar.vue index f1943353..388b77bf 100644 --- a/src/presentation/components/app-navbar/AppNavbar.vue +++ b/src/presentation/components/app-navbar/AppNavbar.vue @@ -3,6 +3,7 @@ @@ -11,33 +12,37 @@ @click="(tab) => router.push(tab.id)" @discard="(tab) => closeTab(tab.id)" /> - + + + diff --git a/src/presentation/components/pageHeading/PageHeading.vue b/src/presentation/components/pageHeading/PageHeading.vue index 8355abd4..06b75f27 100644 --- a/src/presentation/components/pageHeading/PageHeading.vue +++ b/src/presentation/components/pageHeading/PageHeading.vue @@ -1,15 +1,21 @@ @@ -20,13 +26,35 @@ import { Heading } from '@codexteam/ui/vue'; diff --git a/src/presentation/components/tabs/Tab.vue b/src/presentation/components/tabs/Tab.vue deleted file mode 100644 index d3cbca89..00000000 --- a/src/presentation/components/tabs/Tab.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - - -./types/Tab diff --git a/src/presentation/components/tabs/Tabs.vue b/src/presentation/components/tabs/Tabs.vue deleted file mode 100644 index 2db011a7..00000000 --- a/src/presentation/components/tabs/Tabs.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/src/presentation/components/tabs/types/Tab.ts b/src/presentation/components/tabs/types/Tab.ts deleted file mode 100644 index ba77e04a..00000000 --- a/src/presentation/components/tabs/types/Tab.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Props for Tab component - */ -export default interface Tab { - /** - * Tab title - */ - title: string; - - /** - * Link path - */ - path: string; - - /** - * Tab icon - */ - icon?: string; - - /** - * Is tab active - */ - isActive?: boolean; - - /** - * Is tab pinned, in other words, can't be closed - */ - isPinned?: boolean; - - /** - * On close callback - */ - onClose?: () => void; -} diff --git a/src/presentation/components/team/MoreActions.vue b/src/presentation/components/team/MoreActions.vue index 89c57d9f..19688311 100644 --- a/src/presentation/components/team/MoreActions.vue +++ b/src/presentation/components/team/MoreActions.vue @@ -2,71 +2,82 @@ diff --git a/src/presentation/components/team/RoleSelect.vue b/src/presentation/components/team/RoleSelect.vue index 2da09e2e..60d5db57 100644 --- a/src/presentation/components/team/RoleSelect.vue +++ b/src/presentation/components/team/RoleSelect.vue @@ -1,69 +1,55 @@ - - diff --git a/src/presentation/components/team/Team.vue b/src/presentation/components/team/Team.vue index 17a35df6..252a3e94 100644 --- a/src/presentation/components/team/Team.vue +++ b/src/presentation/components/team/Team.vue @@ -7,55 +7,73 @@ v-for="(member, memberIndex) in sortedTeam" :key="member.id" :title="member.user.name || member.user.email" + :subtitle="member.user.name ? member.user.email : undefined" :has-delimiter="memberIndex !== sortedTeam.length - 1" - data-dimensions="medium" > - - + + diff --git a/src/presentation/components/theme/ThemeButton.vue b/src/presentation/components/theme/ThemeButton.vue deleted file mode 100644 index 285cbe75..00000000 --- a/src/presentation/components/theme/ThemeButton.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/src/presentation/components/toast/Toasts.vue b/src/presentation/components/toast/Toasts.vue new file mode 100644 index 00000000..d5cb473b --- /dev/null +++ b/src/presentation/components/toast/Toasts.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/presentation/pages/AuthorizationPage.vue b/src/presentation/pages/AuthorizationPage.vue index 932f01c5..e343ccf2 100644 --- a/src/presentation/pages/AuthorizationPage.vue +++ b/src/presentation/pages/AuthorizationPage.vue @@ -1,21 +1,27 @@ diff --git a/src/presentation/pages/Error.vue b/src/presentation/pages/Error.vue index a1dd6496..67ea672a 100644 --- a/src/presentation/pages/Error.vue +++ b/src/presentation/pages/Error.vue @@ -1,24 +1,28 @@ diff --git a/src/presentation/pages/History.vue b/src/presentation/pages/History.vue index 47848b20..9a7a64b5 100644 --- a/src/presentation/pages/History.vue +++ b/src/presentation/pages/History.vue @@ -1,33 +1,41 @@ - diff --git a/src/presentation/pages/HistoryVersion.vue b/src/presentation/pages/HistoryVersion.vue index c0a32343..b2db3f98 100644 --- a/src/presentation/pages/HistoryVersion.vue +++ b/src/presentation/pages/HistoryVersion.vue @@ -1,36 +1,40 @@