diff --git a/apps/client/src/menus/context_menu.ts b/apps/client/src/menus/context_menu.ts index a23421651..788ece177 100644 --- a/apps/client/src/menus/context_menu.ts +++ b/apps/client/src/menus/context_menu.ts @@ -17,11 +17,17 @@ interface MenuSeparatorItem { title: "----"; } +export interface MenuItemBadge { + title: string; + className?: string; +} + export interface MenuCommandItem { title: string; command?: T; type?: string; uiIcon?: string; + badges?: MenuItemBadge[]; templateNoteId?: string; enabled?: boolean; handler?: MenuHandler; @@ -161,6 +167,18 @@ class ContextMenu { .append("   ") // some space between icon and text .append(item.title); + if ("badges" in item && item.badges) { + for (let badge of item.badges) { + const badgeElement = $(``).text(badge.title); + + if (badge.className) { + badgeElement.addClass(badge.className); + } + + $link.append(badgeElement); + } + } + if ("shortcut" in item && item.shortcut) { $link.append($("").text(item.shortcut)); } diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 7d5b884d9..d35e7df8b 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -1,42 +1,87 @@ -import server from "./server.js"; -import froca from "./froca.js"; import { t } from "./i18n.js"; -import type { MenuItem } from "../menus/context_menu.js"; +import froca from "./froca.js"; +import server from "./server.js"; +import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js"; +import type { NoteType } from "../entities/fnote.js"; import type { TreeCommandNames } from "../menus/tree_context_menu.js"; +export interface NoteTypeMapping { + type: NoteType; + mime?: string; + title: string; + icon?: string; + /** Indicates whether this type should be marked as a newly introduced feature. */ + isNew?: boolean; + /** Indicates that this note type is part of a beta feature. */ + isBeta?: boolean; + /** Indicates that this note type cannot be created by the user. */ + reserved?: boolean; + /** Indicates that once a note of this type is created, its type can no longer be changed. */ + static?: boolean; +} + +export const NOTE_TYPES: NoteTypeMapping[] = [ + // The suggested note type ordering method: insert the item into the corresponding group, + // then ensure the items within the group are ordered alphabetically. + + // The default note type (always the first item) + { type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" }, + + // Text notes group + { type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" }, + + // Graphic notes + { type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" }, + { type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" }, + + // Map notes + { type: "geoMap", mime: "application/json", title: t("note_types.geo-map"), icon: "bx-map-alt", isBeta: true }, + { type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" }, + { type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true }, + { type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" }, + + // Misc note types + { type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" }, + { type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true }, + { type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" }, + + // Code notes + { type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" }, + + // Reserved types (cannot be created by the user) + { type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true }, + { type: "doc", mime: "", title: t("note_types.doc"), reserved: true }, + { type: "file", title: t("note_types.file"), reserved: true }, + { type: "image", title: t("note_types.image"), reserved: true }, + { type: "launcher", mime: "", title: t("note_types.launcher"), reserved: true }, + { type: "aiChat", mime: "application/json", title: t("note_types.ai-chat"), reserved: true } +]; + +/** The maximum age in days for a template to be marked with the "New" badge */ +const NEW_TEMPLATE_MAX_AGE = 3; + +/** The length of a day in milliseconds. */ +const DAY_LENGTH = 1000 * 60 * 60 * 24; + +/** The menu item badge used to mark new note types and templates */ +const NEW_BADGE: MenuItemBadge = { + title: t("note_types.new-feature"), + className: "new-note-type-badge" +}; + +/** The menu item badge used to mark note types that are part of a beta feature */ +const BETA_BADGE = { + title: t("note_types.beta-feature") +}; + const SEPARATOR = { title: "----" }; +const creationDateCache = new Map(); +let rootCreationDate: Date | undefined; + async function getNoteTypeItems(command?: TreeCommandNames) { const items: MenuItem[] = [ - // The suggested note type ordering method: insert the item into the corresponding group, - // then ensure the items within the group are ordered alphabetically. - // Please keep the order synced with the listing found also in aps/client/src/widgets/note_types.ts. - - // The default note type (always the first item) - { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, - - // Text notes group - { title: t("note_types.book"), command, type: "book", uiIcon: "bx bx-book" }, - - // Graphic notes - { title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" }, - { title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" }, - - // Map notes - { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, - { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" }, - { title: t("note_types.note-map"), command, type: "noteMap", uiIcon: "bx bxs-network-chart" }, - { title: t("note_types.relation-map"), command, type: "relationMap", uiIcon: "bx bxs-network-chart" }, - - // Misc note types - { title: t("note_types.render-note"), command, type: "render", uiIcon: "bx bx-extension" }, - { title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" }, - { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" }, - - // Code notes - { title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" }, - - // Templates + ...getBlankNoteTypes(command), ...await getBuiltInTemplates(command), ...await getUserTemplates(command) ]; @@ -44,6 +89,28 @@ async function getNoteTypeItems(command?: TreeCommandNames) { return items; } +function getBlankNoteTypes(command): MenuItem[] { + return NOTE_TYPES.filter((nt) => !nt.reserved).map((nt) => { + const menuItem: MenuCommandItem = { + title: nt.title, + command, + type: nt.type, + uiIcon: "bx " + nt.icon, + badges: [] + } + + if (nt.isNew) { + menuItem.badges?.push(NEW_BADGE); + } + + if (nt.isBeta) { + menuItem.badges?.push(BETA_BADGE); + } + + return menuItem; + }); +} + async function getUserTemplates(command?: TreeCommandNames) { const templateNoteIds = await server.get("search-templates"); const templateNotes = await froca.getNotes(templateNoteIds); @@ -54,14 +121,21 @@ async function getUserTemplates(command?: TreeCommandNames) { const items: MenuItem[] = [ SEPARATOR ]; + for (const templateNote of templateNotes) { - items.push({ + const item: MenuItem = { title: templateNote.title, uiIcon: templateNote.getIcon(), command: command, type: templateNote.type, templateNoteId: templateNote.noteId - }); + }; + + if (await isNewTemplate(templateNote.noteId)) { + item.badges = [NEW_BADGE]; + } + + items.push(item); } return items; } @@ -81,18 +155,71 @@ async function getBuiltInTemplates(command?: TreeCommandNames) { const items: MenuItem[] = [ SEPARATOR ]; + for (const templateNote of childNotes) { - items.push({ + const item: MenuItem = { title: templateNote.title, uiIcon: templateNote.getIcon(), command: command, type: templateNote.type, templateNoteId: templateNote.noteId - }); + }; + + if (await isNewTemplate(templateNote.noteId)) { + item.badges = [NEW_BADGE]; + } + + items.push(item); } return items; } +async function isNewTemplate(templateNoteId) { + if (rootCreationDate === undefined) { + // Retrieve the root note creation date + try { + let rootNoteInfo: any = await server.get("notes/root"); + if ("dateCreated" in rootNoteInfo) { + rootCreationDate = new Date(rootNoteInfo.dateCreated); + } + } catch (ex) { + console.error(ex); + } + } + + // Try to retrieve the template's creation date from the cache + let creationDate: Date | undefined = creationDateCache.get(templateNoteId); + + if (creationDate === undefined) { + // The creation date isn't available in the cache, try to retrieve it from the server + try { + const noteInfo: any = await server.get("notes/" + templateNoteId); + if ("dateCreated" in noteInfo) { + creationDate = new Date(noteInfo.dateCreated); + creationDateCache.set(templateNoteId, creationDate); + } + } catch (ex) { + console.error(ex); + } + } + + if (creationDate) { + if (rootCreationDate && creationDate.getTime() - rootCreationDate.getTime() < 30000) { + // Ignore templates created within 30 seconds after the root note is created. + // This is useful to prevent predefined templates from being marked + // as 'New' after setting up a new database. + return false; + } + + // Determine the difference in days between now and the template's creation date + const age = (new Date().getTime() - creationDate.getTime()) / DAY_LENGTH; + // Return true if the template is at most NEW_TEMPLATE_MAX_AGE days old + return (age <= NEW_TEMPLATE_MAX_AGE); + } else { + return false; + } +} + export default { getNoteTypeItems }; diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 0bf0588b0..d44fc5b11 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -192,6 +192,13 @@ samp { font-family: var(--monospace-font-family) !important; } +.badge { + --bs-badge-color: var(--muted-text-color); + + margin-left: 8px; + background: var(--accented-background-color); +} + .input-group-text { background-color: var(--accented-background-color) !important; color: var(--muted-text-color) !important; diff --git a/apps/client/src/stylesheets/theme-next-dark.css b/apps/client/src/stylesheets/theme-next-dark.css index 031bdf465..b27627bd1 100644 --- a/apps/client/src/stylesheets/theme-next-dark.css +++ b/apps/client/src/stylesheets/theme-next-dark.css @@ -178,6 +178,9 @@ --alert-bar-background: #6b6b6b3b; + --badge-background-color: #ffffff1a; + --badge-text-color: var(--muted-text-color); + --promoted-attribute-card-background-color: var(--card-background-color); --promoted-attribute-card-shadow-color: #000000b3; diff --git a/apps/client/src/stylesheets/theme-next-light.css b/apps/client/src/stylesheets/theme-next-light.css index ba994587f..ff82e99ba 100644 --- a/apps/client/src/stylesheets/theme-next-light.css +++ b/apps/client/src/stylesheets/theme-next-light.css @@ -171,6 +171,9 @@ --alert-bar-background: #32637b29; + --badge-background-color: #00000011; + --badge-text-color: var(--muted-text-color); + --promoted-attribute-card-background-color: var(--card-background-color); --promoted-attribute-card-shadow-color: #00000033; diff --git a/apps/client/src/stylesheets/theme-next/base.css b/apps/client/src/stylesheets/theme-next/base.css index 9058328c3..116a8ebdd 100644 --- a/apps/client/src/stylesheets/theme-next/base.css +++ b/apps/client/src/stylesheets/theme-next/base.css @@ -171,6 +171,16 @@ html body .dropdown-item[disabled] { opacity: var(--menu-item-disabled-opacity); } +/* Badges */ +:root .badge { + --bs-badge-color: var(--badge-text-color); + --bs-badge-font-weight: 500; + + background: var(--badge-background-color); + text-transform: uppercase; + letter-spacing: .2pt; +} + /* Menu item icon */ .dropdown-item .bx { transform: translateY(var(--menu-item-icon-vert-offset)); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index b2d2f0a27..1f992175c 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1627,7 +1627,8 @@ "geo-map": "Geo Map", "beta-feature": "Beta", "ai-chat": "AI Chat", - "task-list": "Task List" + "task-list": "Task List", + "new-feature": "New" }, "protect_note": { "toggle-on": "Protect the note", diff --git a/apps/client/src/widgets/dialogs/note_type_chooser.ts b/apps/client/src/widgets/dialogs/note_type_chooser.ts index 02b960308..34a89aff6 100644 --- a/apps/client/src/widgets/dialogs/note_type_chooser.ts +++ b/apps/client/src/widgets/dialogs/note_type_chooser.ts @@ -154,13 +154,21 @@ export default class NoteTypeChooserDialog extends BasicWidget { this.$noteTypeDropdown.append($('