diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 443014572..161846dde 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -261,7 +261,6 @@ export type CommandMappings = { // Geomap deleteFromMap: { noteId: string }; - openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent }; toggleZenMode: CommandData; diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 020817073..75c66b1bc 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -326,7 +326,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> } // Some book types must always display a note list, even if no children. - if (["calendar", "table"].includes(note.getLabelValue("viewType") ?? "")) { + if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) { return true; } diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index fd35c09b8..b5d575ed9 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -27,7 +27,6 @@ const NOTE_TYPE_ICONS = { doc: "bx bxs-file-doc", contentWidget: "bx bxs-widget", mindMap: "bx bx-sitemap", - geoMap: "bx bx-map-alt", aiChat: "bx bx-bot" }; @@ -36,7 +35,7 @@ const NOTE_TYPE_ICONS = { * end user. Those types should be used only for checking against, they are * not for direct use. */ -export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "geoMap" | "aiChat"; +export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat"; export interface NotePathRecord { isArchived: boolean; 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/link.ts b/apps/client/src/services/link.ts index 41533647c..a16f0bccf 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -277,13 +277,21 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) { return goToLinkExt(evt, hrefLink, $link); } -function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent, hrefLink: string | undefined, $link?: JQuery | null) { +/** + * Handles navigation to a link, which can be an internal note path (e.g., `#root/1234`) or an external URL (e.g., `https://example.com`). + * + * @param evt the event that triggered the link navigation, or `null` if the link was clicked programmatically. Used to determine if the link should be opened in a new tab/window, based on the button presses. + * @param hrefLink the link to navigate to, which can be a note path (e.g., `#root/1234`) or an external URL with any supported protocol (e.g., `https://example.com`). + * @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly. + * @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise. + */ +function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent | null, hrefLink: string | undefined, $link?: JQuery | null) { if (hrefLink?.startsWith("data:")) { return true; } - evt.preventDefault(); - evt.stopPropagation(); + evt?.preventDefault(); + evt?.stopPropagation(); if (hrefLink && hrefLink.startsWith("#") && !hrefLink.startsWith("#root/") && $link) { if (handleAnchor(hrefLink, $link)) { @@ -293,14 +301,14 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent const { notePath, viewScope } = parseNavigationStateFromUrl(hrefLink); - const ctrlKey = utils.isCtrlKey(evt); - const shiftKey = evt.shiftKey; - const isLeftClick = "which" in evt && evt.which === 1; - const isMiddleClick = "which" in evt && evt.which === 2; + const ctrlKey = evt && utils.isCtrlKey(evt); + const shiftKey = evt?.shiftKey; + const isLeftClick = !evt || ("which" in evt && evt.which === 1); + const isMiddleClick = evt && "which" in evt && evt.which === 2; const targetIsBlank = ($link?.attr("target") === "_blank"); const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank; const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey); - const openInNewWindow = isLeftClick && evt.shiftKey && !ctrlKey; + const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey; if (notePath) { if (openInNewWindow) { @@ -311,7 +319,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent viewScope }); } else if (isLeftClick) { - const ntxId = $(evt.target as any) + const ntxId = $(evt?.target as any) .closest("[data-ntx-id]") .attr("data-ntx-id"); diff --git a/apps/client/src/services/note_list_renderer.ts b/apps/client/src/services/note_list_renderer.ts index 3219d8d92..122ba9745 100644 --- a/apps/client/src/services/note_list_renderer.ts +++ b/apps/client/src/services/note_list_renderer.ts @@ -1,11 +1,12 @@ import type FNote from "../entities/fnote.js"; import CalendarView from "../widgets/view_widgets/calendar_view.js"; +import GeoView from "../widgets/view_widgets/geo_view/index.js"; import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js"; import TableView from "../widgets/view_widgets/table_view/index.js"; import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js"; import type ViewMode from "../widgets/view_widgets/view_mode.js"; -export type ViewTypeOptions = "list" | "grid" | "calendar" | "table"; +export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap"; export default class NoteListRenderer { @@ -26,6 +27,9 @@ export default class NoteListRenderer { case "table": this.viewMode = new TableView(args); break; + case "geoMap": + this.viewMode = new GeoView(args); + break; default: this.viewMode = null; } @@ -34,7 +38,7 @@ export default class NoteListRenderer { #getViewType(parentNote: FNote): ViewTypeOptions { const viewType = parentNote.getLabelValue("viewType"); - if (!["list", "grid", "calendar", "table"].includes(viewType || "")) { + if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) { // when not explicitly set, decide based on the note type return parentNote.type === "search" ? "list" : "grid"; } else { diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 7d5b884d9..9f566f487 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -1,42 +1,86 @@ -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: "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 +88,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 +120,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 +154,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..00cb9f227 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -761,7 +761,8 @@ "book_properties": "Book Properties", "invalid_view_type": "Invalid view type '{{type}}'", "calendar": "Calendar", - "table": "Table" + "table": "Table", + "geo-map": "Geo Map" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", @@ -1627,7 +1628,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", @@ -1858,7 +1860,8 @@ }, "geo-map-context": { "open-location": "Open location", - "remove-from-map": "Remove from map" + "remove-from-map": "Remove from map", + "add-note": "Add a marker at this location" }, "help-button": { "title": "Open the relevant help page" diff --git a/apps/client/src/widgets/buttons/note_actions.ts b/apps/client/src/widgets/buttons/note_actions.ts index 6989d8152..9bef36f3a 100644 --- a/apps/client/src/widgets/buttons/note_actions.ts +++ b/apps/client/src/widgets/buttons/note_actions.ts @@ -189,7 +189,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget { this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type)); this.toggleDisabled(this.$showAttachmentsButton, !isInOptions); - this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type)); + this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type)); const canPrint = ["text", "code"].includes(note.type); this.toggleDisabled(this.$printActiveNoteButton, canPrint); 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($('