diff --git a/.agents/skills/jotty/references/auth.md b/.agents/skills/jotty/references/auth.md index 1c03bc116..4e0d63ee3 100644 --- a/.agents/skills/jotty/references/auth.md +++ b/.agents/skills/jotty/references/auth.md @@ -32,7 +32,7 @@ OIDC redirect URI has to match the IdP exactly, scheme included. ## API keys -`authenticateApiKey` in `actions/api`. Header `x-api-key`. Keys are hashed at rest. Generating a new one is an audit event. See [api.md](api.md). +`authenticateApiKey` in `actions/api`. Header `x-api-key`. Keys are stored in plain text in `users.json` on purpose, so users can see theirs again in Profile. Generate them with `crypto.randomBytes` and compare them timing-safe, never with `===`. Never log one. See [api.md](api.md). ## Sanitising diff --git a/.agents/skills/jotty/references/persistence.md b/.agents/skills/jotty/references/persistence.md index 5e9d26b0a..97ddeb03c 100644 --- a/.agents/skills/jotty/references/persistence.md +++ b/.agents/skills/jotty/references/persistence.md @@ -1,6 +1,6 @@ # File persistence -There is no database. A missed lock, a half-written file, or a loop that stats 800 notes is a real outage for a real person. +Your files are the database. The only other store is a derived SQLite index of relationships, which can always be rebuilt from them. A missed lock, a half-written file, or a loop that stats 800 notes is a real outage for a real person. ## Layout @@ -11,12 +11,12 @@ data/ users/session-data.json notes// markdown notes in category folders checklists// markdown lists in category folders - notes//.index.json link index, keyed by uuid /.category-info.json folder uuid, sharing, order notifications/.json encryption// PGP key files, never the passphrase logs/ .schema-version + .relations.db derived links index (SQLite, WAL). Disposable. ``` `.sharing.json` and `.order.json` are leftovers. Current code reads them during migration. New writes go to `.category-info.json`. @@ -27,7 +27,7 @@ Schema version is `DATA_SCHEMA_VERSION` in `app/_consts/files.ts`. Old shapes st Notes and checklists are both `.md` with YAML frontmatter. Checklists are not JSON. `noteToMarkdown` / `listToMarkdown` write them. Readers parse them. -Frontmatter carries `uuid`, `title`, timestamps, tags, encryption flags, checklist type. Body is markdown. Encrypted body is opaque ciphertext. Do not index it. +Frontmatter carries `uuid`, `title`, `createdAt`, tags, encryption flags, checklist type. Body is markdown. Encrypted body is opaque ciphertext. Do not index it. ## Helpers @@ -35,10 +35,11 @@ Frontmatter carries `uuid`, `title`, timestamps, tags, encryption flags, checkli - `ensureDir` - `readJsonFile` / `writeJsonFile` (atomic temp + rename) -- `serverReadFile` / `serverWriteFile` / `serverDeleteFile` +- `serverReadFile` / `serverWriteFile` / `serverDeleteFile` / `serverDeleteDir` / `serverRenamePath` - `getUserModeDir` +- `stampUuid` in `lib/stamp-uuid.ts` for giving an item file its uuid -`writeCatInfo` is also atomic. `serverWriteFile` is a straight write. Prefer the atomic helpers for JSON. Do not invent a third writer. +`serverWriteFile` is atomic (temp file + rename), stamps `createdAt` on item files that lack it, invalidates the metadata cache and updates the relations index. Delete, rename and move go through the helpers above for the same reasons. A raw `fs.writeFile` or `fs.rename` on an item file skips all of that. Do not invent another writer. Paths: `path.join(process.cwd(), ...)`. Constants in `app/_consts/files.ts`. Never a relative `"data/..."` you hope is cwd. @@ -67,11 +68,15 @@ Jotty is one Node process. Those in-process maps are enough until someone cluste ## Indexes -`.index.json` under notes is the **link** index, keyed by item uuid. Create, update, delete, and move must call `updateIndexForItem` / `removeItemFromIndex`. It does not rebuild itself. +`data/.relations.db` is the **relations** index, in `app/_server/actions/relations/`. It holds items, links between them, wikilink bindings and plain note text for "Mentioned in". It is derived and disposable: a missing, corrupt or old-schema file is discarded and rebuilt from the markdown on start, with the UI showing "Indexing relationships...". Bump `RELATIONS_SCHEMA_VERSION` when its shape changes. No migration, it just rebuilds. -Folder order and sharing live in `.category-info.json` (`order.items` is a uuid list). +- Writes keep it current through the file helpers (`trackItemWrite` and friends in `relations/tracking.ts`). Use the helpers and you get it for free. +- Changes made outside Jotty are caught by a recursive `fs.watch` on the notes and checklists roots, throttled to one pass a minute, which only stats the paths that changed. If the watcher can't start, reads fall back to a full mtime reconcile at most once a minute. +- Encrypted notes are indexed by uuid and title only. Their body is never parsed. +- `bindings` remembers which uuid each `[[wikilink]]` text first resolved to, per source note. That memory survives edits and rebuilds, and is lost only if the file is deleted. +- Queries are permission scoped through `visibleItems(username)`. Never return rows the viewer can't see. -If you add a write path and forget the index, search and the graph go stale with nothing in the logs. +Folder order and sharing live in `.category-info.json` (`order.items` is a uuid list). ## Path containment @@ -83,4 +88,4 @@ Username is not a path segment you trust from the client either. Session usernam ## Data on this machine -The `data/` directory in a running instance holds real notes. Tests use mocks and temp dirs. `yarn mock:data:notes` / `yarn mock:data:lists` fill a named user for local poking. Do not empty, reshape, or "fix" `data/` to make a test pass. +The `data/` directory in a running instance holds real notes. Tests use mocks and temp dirs. `yarn mock:data:notes` / `yarn mock:data:lists` fill a named user for local poking. `yarn mock:data:brain --user=` writes a linked set under a `Brain Seed` category, and `--remove` deletes only that category. Do not empty, reshape, or "fix" `data/` to make a test pass. diff --git a/.github/workflows/mcp-docker-publish.yml b/.github/workflows/mcp-docker-publish.yml new file mode 100644 index 000000000..eb9a214d0 --- /dev/null +++ b/.github/workflows/mcp-docker-publish.yml @@ -0,0 +1,51 @@ +name: Build and Publish MCP + +on: + push: + branches: ["main", "develop"] + tags: ["*"] + +jobs: + build-mcp-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: The MCP is called jotty-mcp so we keep it jotty-mcp + id: repo + run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')-mcp" >> $GITHUB_OUTPUT + + - name: Buildx baby + uses: docker/setup-buildx-action@v3 + + - name: Boring github login + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Same tags as jotty, so the versions line up + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.repo.outputs.name }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + + - name: One small image, both arches, no emulation + uses: docker/build-push-action@v5 + with: + context: mcp-server + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=mcp + cache-to: type=gha,mode=max,scope=mcp diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 94e3ea02a..e27974a33 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -38,7 +38,7 @@ jobs: - name: Setup the best engine ever uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: "yarn" - name: Install all dependencies @@ -58,3 +58,31 @@ jobs: - name: Server actions also need to be tested run: yarn test:run tests/server-actions --reporter=verbose + + mcp: + name: The MCP server gets poked too + runs-on: ubuntu-latest + needs: validate-branch + defaults: + run: + working-directory: mcp-server + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Bun, because the MCP is fancy + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Install MCP dependencies + run: bun install --frozen-lockfile + + - name: Types or it didn't happen + run: bun run typecheck + + - name: Fake Jotty, real HTTP + run: bun test + + - name: Make sure it still bundles + run: bun run build diff --git a/.github/workflows/prebuild-release.yml b/.github/workflows/prebuild-release.yml index 5f53d32f2..a7258a066 100644 --- a/.github/workflows/prebuild-release.yml +++ b/.github/workflows/prebuild-release.yml @@ -16,7 +16,7 @@ jobs: - name: Setup the best engine ever uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'yarn' - name: Install dependencies @@ -52,9 +52,63 @@ jobs: sha256sum jotty_${{ steps.version.outputs.version }}_prebuild.tar.gz > jotty_${{ steps.version.outputs.version }}_prebuild.tar.gz.sha256 - name: Attach to Release - pray it works - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: | prebuild-release/jotty_*_prebuild.tar.gz prebuild-release/jotty_*_prebuild.tar.gz.sha256 tag_name: ${{ steps.version.outputs.version }} + + build-mcp: + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + working-directory: mcp-server + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup the other best engine ever + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Make sure it still works + run: | + bun run typecheck + bun test + + - name: Bake the MCP + run: bun run build + + - name: Get version from tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Structure the prebuild stuff + run: | + mkdir -p prebuild-release/jotty-mcp + cp dist/main.js prebuild-release/jotty-mcp/main.js + cp README.md prebuild-release/jotty-mcp/README.md + cp ../LICENSE prebuild-release/jotty-mcp/LICENSE + + - name: Create tarball - a smaller funny name + run: | + cd prebuild-release + tar -czf jotty-mcp_${{ steps.version.outputs.version }}_prebuild.tar.gz jotty-mcp + sha256sum jotty-mcp_${{ steps.version.outputs.version }}_prebuild.tar.gz > jotty-mcp_${{ steps.version.outputs.version }}_prebuild.tar.gz.sha256 + + - name: Attach to Release - pray it works twice + uses: softprops/action-gh-release@v2 + with: + files: | + mcp-server/prebuild-release/jotty-mcp_*_prebuild.tar.gz + mcp-server/prebuild-release/jotty-mcp_*_prebuild.tar.gz.sha256 + tag_name: ${{ steps.version.outputs.version }} diff --git a/AGENTS.md b/AGENTS.md index ef7f48ce7..3b461ee16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Jotty -Jotty is a self-hosted app for notes, checklists and Kanban boards. There is no database. Every note, checklist, user and share is a file on a disk that the person running it owns. +Jotty is a self-hosted app for notes, checklists and Kanban boards. Every note, checklist, user and share is a file on a disk that the person running it owns. The one database, `data/.relations.db`, is a derived index of links. Deleting it loses nothing anybody wrote: it is rebuilt from those files. Never make it the only home of user data. If we corrupt one of those files there is no restore button and nobody to email. There is a person and whatever backup they happened to take. @@ -12,7 +12,7 @@ Read, change, write back on a shared file without a lock loses data. It has happ A write that fails halfway leaves a truncated note where a note used to be. Use the existing file helpers, they write atomically. -Indexes do not rebuild themselves. Add a write path, miss the rebuild, and search goes stale with nothing in the logs to say so. +Indexes go stale quietly. The file helpers keep the caches and the relations index in step, so write through them. A raw `fs` write on an item file leaves search, backlinks and the brain wrong with nothing in the logs to say so. Loops are expensive. One helper reading one item is fine. That same helper running once per item for a user with 800 notes is a stat storm. Caches and indexes already exist for this, look for them before you walk a directory yourself. @@ -92,4 +92,14 @@ Same words for the same things, please. Smallest thing that proves the change works. Type check, lint what you touched, run the tests covering the area, and the whole suite if you changed something shared. -The security tests cover auth, path containment and data leakage. If your change makes one fail, the change is wrong until proven otherwise. If you changed behaviour the tests cover, update them and tell me you did. \ No newline at end of file +The security tests cover auth, path containment and data leakage. If your change makes one fail, the change is wrong until proven otherwise. If you changed behaviour the tests cover, update them and tell me you did. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/Dockerfile b/Dockerfile index 77cf66802..c018f820e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:24-alpine AS base FROM base AS deps RUN apk add --no-cache libc6-compat diff --git a/README.md b/README.md index 8e4caf5d0..c3dae4cfe 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ My recommended way to run `jotty·page` is with Docker. You can also use: - The [Proxmox community script](https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/jotty.sh) for Proxmox VE - The [Unraid template](howto/UNRAID.md) for Unraid Community Applications +Running it without Docker, from source or from the prebuilt tarball, needs Node.js 22.15 or later in the 22.x series, or Node.js 23.11 or later. + ### Docker Compose (Recommended) @@ -186,6 +188,8 @@ I will always detail these migrations in the release notes. I _highly recommend_ 📖 **For the complete MARKDOWN documentation, see [howto/MARKDOWN.md](howto/MARKDOWN.md)** +📖 **For links, wikilinks and the brain view, see [howto/BRAIN.md](howto/BRAIN.md)** + ## ENCRYPTION diff --git a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx index 60e7792db..e7aa1abe5 100644 --- a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { getListById, getUserChecklists, -} from "@/app/_server/actions/checklist"; +} from "@/app/_server/actions/checklist/queries"; import { getCategories } from "@/app/_server/actions/category"; import { getCurrentUser, canAccessAllContent } from "@/app/_server/actions/users"; import { ChecklistClient } from "@/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient"; @@ -11,6 +11,8 @@ import type { Metadata } from "next"; import { getMedatadaTitle } from "@/app/_server/actions/config"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; interface AdminChecklistPageProps { params: Promise<{ @@ -26,6 +28,10 @@ export async function generateMetadata(props: AdminChecklistPageProps): Promise< return getMedatadaTitle(Modes.CHECKLISTS, uuid); } +/** + * Render a checklist with metadata and relations for a user with access to all content. + * Redirect home when access is denied or the checklist cannot be loaded. + */ export default async function AdminChecklistPage(props: AdminChecklistPageProps) { const params = await props.params; const { uuid } = params; @@ -67,14 +73,18 @@ export default async function AdminChecklistPage(props: AdminChecklistPageProps) type: "checklist" as const, }; + const relations = await getItemRelations(checklist.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx index 9f39649d4..d872c7953 100644 --- a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx @@ -1,9 +1,6 @@ import { redirect } from "next/navigation"; -import { - CheckForNeedsMigration, - getNoteById, - getUserNotes, -} from "@/app/_server/actions/note"; +import { CheckForNeedsMigration } from "@/app/_server/actions/note"; +import { getNoteById, getUserNotes } from "@/app/_server/actions/note/queries"; import { getCurrentUser, canAccessAllContent } from "@/app/_server/actions/users"; import { NoteClient } from "@/app/_components/FeatureComponents/Notes/NoteClient"; import { Modes } from "@/app/_types/enums"; @@ -12,6 +9,8 @@ import type { Metadata } from "next"; import { getMedatadaTitle } from "@/app/_server/actions/config"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; interface AdminNotePageProps { params: Promise<{ @@ -27,6 +26,10 @@ export async function generateMetadata(props: AdminNotePageProps): Promise - + + + ); diff --git a/app/(loggedInRoutes)/brain/[username]/page.tsx b/app/(loggedInRoutes)/brain/[username]/page.tsx new file mode 100644 index 000000000..155985702 --- /dev/null +++ b/app/(loggedInRoutes)/brain/[username]/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { BRAIN_FOCUS_PARAM } from "@/app/_consts/relations"; +import { decodeSegment } from "@/app/_utils/global-utils"; +import { BrainPage } from "@/app/_components/FeatureComponents/Brain/server/BrainPage"; + +export const dynamic = "force-dynamic"; + +/** + * Return the localized Brain title for the browser tab. + */ +export async function generateMetadata(): Promise { + const t = await getTranslations("brain"); + return { title: t("title") }; +} + +/** + * Render the Brain for the decoded username from the route. + * Pass the optional focus query parameter to the shared Brain page. + */ +export default async function UserBrainPage(props: { + params: Promise<{ username: string }>; + searchParams: Promise>; +}) { + const [params, searchParams] = await Promise.all([props.params, props.searchParams]); + return ( + + ); +} diff --git a/app/(loggedInRoutes)/brain/page.tsx b/app/(loggedInRoutes)/brain/page.tsx new file mode 100644 index 000000000..e218c3c64 --- /dev/null +++ b/app/(loggedInRoutes)/brain/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { BRAIN_FOCUS_PARAM } from "@/app/_consts/relations"; +import { BrainPage } from "@/app/_components/FeatureComponents/Brain/server/BrainPage"; + +export const dynamic = "force-dynamic"; + +/** + * Return the localized Brain title for the browser tab. + */ +export async function generateMetadata(): Promise { + const t = await getTranslations("brain"); + return { title: t("title") }; +} + +/** + * Render the current user's Brain with the optional focus query parameter. + */ +export default async function OwnBrainPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + return ; +} diff --git a/app/(loggedInRoutes)/checklist/[uuid]/page.tsx b/app/(loggedInRoutes)/checklist/[uuid]/page.tsx index 05afed362..06340b79b 100644 --- a/app/(loggedInRoutes)/checklist/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/checklist/[uuid]/page.tsx @@ -1,5 +1,5 @@ import { redirect, permanentRedirect } from "next/navigation"; -import { getListById } from "@/app/_server/actions/checklist"; +import { getListById } from "@/app/_server/actions/checklist/queries"; import { getCategories } from "@/app/_server/actions/category"; import { getCurrentUser, canAccessAllContent } from "@/app/_server/actions/users"; import { ChecklistClient } from "@/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient"; @@ -10,6 +10,8 @@ import { isUuid } from "@/app/_consts/identity"; import { UNCATEGORIZED } from "@/app/_consts/notes"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; import { decodeSegment } from "@/app/_utils/global-utils"; interface ChecklistPageProps { @@ -25,6 +27,10 @@ export async function generateMetadata(props: ChecklistPageProps): Promise - + + + ); diff --git a/app/(loggedInRoutes)/checklists/page.tsx b/app/(loggedInRoutes)/checklists/page.tsx index 5cb3c8cc0..e122f9c19 100644 --- a/app/(loggedInRoutes)/checklists/page.tsx +++ b/app/(loggedInRoutes)/checklists/page.tsx @@ -1,4 +1,4 @@ -import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { getUserChecklists } from "@/app/_server/actions/checklist/queries"; import { getCurrentUser } from "@/app/_server/actions/users"; import { ChecklistsPageClient } from "@/app/_components/FeatureComponents/Checklists/ChecklistsPageClient"; import { Checklist } from "@/app/_types"; diff --git a/app/(loggedInRoutes)/howto/[path]/page.tsx b/app/(loggedInRoutes)/howto/[path]/page.tsx index 683e68447..d92b83693 100644 --- a/app/(loggedInRoutes)/howto/[path]/page.tsx +++ b/app/(loggedInRoutes)/howto/[path]/page.tsx @@ -4,7 +4,10 @@ import { readFile } from "@/app/_server/actions/file"; import { convertMarkdownToHtml } from "@/app/_utils/markdown-utils"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; -import { getHowtoGuideById, getHowtoFilePath, isValidHowtoGuide } from "@/app/_utils/howto-utils"; +import { API_GUIDE_ID, getHowtoGuideById, getHowtoFilePath, isValidHowtoGuide } from "@/app/_utils/howto-utils"; +import { getSettings } from "@/app/_server/actions/config"; +import { apiSpec } from "@/app/_server/api/spec"; +import { ApiExplorer } from "@/app/_components/FeatureComponents/Howto/ApiExplorer/ApiExplorer"; interface HowtoPageProps { params: Promise<{ @@ -63,9 +66,11 @@ export default async function HowtoPage(props: HowtoPageProps) { } const htmlContent = convertMarkdownToHtml(markdownContent); + const showExplorer = guide.id === API_GUIDE_ID && !(await getSettings())?.isDemo; return ( -
+
+ {showExplorer && }
diff --git a/app/(loggedInRoutes)/kanban/page.tsx b/app/(loggedInRoutes)/kanban/page.tsx index f32b1eba4..5da6b164c 100644 --- a/app/(loggedInRoutes)/kanban/page.tsx +++ b/app/(loggedInRoutes)/kanban/page.tsx @@ -1,4 +1,4 @@ -import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { getUserChecklists } from "@/app/_server/actions/checklist/queries"; import { getCurrentUser } from "@/app/_server/actions/users"; import { KanbanPageClient } from "@/app/_components/FeatureComponents/Kanban/KanbanPageClient"; import { Checklist } from "@/app/_types"; diff --git a/app/(loggedInRoutes)/note/[uuid]/page.tsx b/app/(loggedInRoutes)/note/[uuid]/page.tsx index 3b04cefde..9bef0f686 100644 --- a/app/(loggedInRoutes)/note/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/note/[uuid]/page.tsx @@ -1,8 +1,6 @@ import { redirect, permanentRedirect } from "next/navigation"; -import { - CheckForNeedsMigration, - getNoteById, -} from "@/app/_server/actions/note"; +import { CheckForNeedsMigration } from "@/app/_server/actions/note"; +import { getNoteById } from "@/app/_server/actions/note/queries"; import { getCurrentUser, canAccessAllContent, @@ -16,6 +14,8 @@ import { isUuid } from "@/app/_consts/identity"; import { UNCATEGORIZED } from "@/app/_consts/notes"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; import { decodeSegment } from "@/app/_utils/global-utils"; interface NotePageProps { @@ -31,6 +31,10 @@ export async function generateMetadata(props: NotePageProps): Promise return getMedatadaTitle(Modes.NOTES, params.uuid); } +/** + * Resolve a note for the signed-in user and load its categories and relations. + * Migrate legacy notes, redirect old slugs to UUID URLs, and redirect home if unavailable. + */ export default async function NotePage(props: NotePageProps) { const params = await props.params; const { uuid } = params; @@ -90,10 +94,14 @@ export default async function NotePage(props: NotePageProps) { type: "note" as const, }; + const relations = await getItemRelations(note.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/notes/page.tsx b/app/(loggedInRoutes)/notes/page.tsx index e1b5745ee..918f3493b 100644 --- a/app/(loggedInRoutes)/notes/page.tsx +++ b/app/(loggedInRoutes)/notes/page.tsx @@ -1,4 +1,4 @@ -import { getUserNotes } from "@/app/_server/actions/note"; +import { getUserNotes } from "@/app/_server/actions/note/queries"; import { getCurrentUser } from "@/app/_server/actions/users"; import { NotesPageClient } from "@/app/_components/FeatureComponents/Notes/NotesPageClient"; import { Note } from "@/app/_types"; diff --git a/app/(loggedInRoutes)/page.tsx b/app/(loggedInRoutes)/page.tsx index 4960a52be..625a6eb9a 100644 --- a/app/(loggedInRoutes)/page.tsx +++ b/app/(loggedInRoutes)/page.tsx @@ -1,9 +1,7 @@ import { getCategories } from "@/app/_server/actions/category"; -import { getUserChecklists } from "@/app/_server/actions/checklist"; -import { - getUserNotes, - CheckForNeedsMigration, -} from "@/app/_server/actions/note"; +import { getUserChecklists } from "@/app/_server/actions/checklist/queries"; +import { CheckForNeedsMigration } from "@/app/_server/actions/note"; +import { getUserNotes } from "@/app/_server/actions/note/queries"; import { HomeClient } from "@/app/_components/FeatureComponents/Home/HomeClient"; import { getCurrentUser } from "@/app/_server/actions/users"; import { Modes } from "@/app/_types/enums"; diff --git a/app/(loggedInRoutes)/settings/admin/content/page.tsx b/app/(loggedInRoutes)/settings/admin/content/page.tsx index 9455256ee..e92cd54e2 100644 --- a/app/(loggedInRoutes)/settings/admin/content/page.tsx +++ b/app/(loggedInRoutes)/settings/admin/content/page.tsx @@ -1,6 +1,6 @@ import { AdminContent } from "@/app/_components/FeatureComponents/Admin/Parts/AdminContent"; -import { getAllLists } from "@/app/_server/actions/checklist"; -import { getAllNotes } from "@/app/_server/actions/note"; +import { getAllLists } from "@/app/_server/actions/checklist/queries"; +import { getAllNotes } from "@/app/_server/actions/note/queries"; import { canAccessAllContent, getUsersForAdmin } from "@/app/_server/actions/users"; import { notFound } from "next/navigation"; diff --git a/app/(loggedInRoutes)/settings/admin/overview/page.tsx b/app/(loggedInRoutes)/settings/admin/overview/page.tsx index d37149d70..23ec19659 100644 --- a/app/(loggedInRoutes)/settings/admin/overview/page.tsx +++ b/app/(loggedInRoutes)/settings/admin/overview/page.tsx @@ -1,8 +1,8 @@ import { notFound } from "next/navigation"; import { AdminOverview } from "@/app/_components/FeatureComponents/Admin/Parts/AdminOverview"; import { isAdmin, getUsersForAdmin } from "@/app/_server/actions/users"; -import { getAllLists } from "@/app/_server/actions/checklist"; -import { getAllNotes } from "@/app/_server/actions/note"; +import { getAllLists } from "@/app/_server/actions/checklist/queries"; +import { getAllNotes } from "@/app/_server/actions/note/queries"; export default async function AdminOverviewPage() { const admin = await isAdmin(); diff --git a/app/(loggedInRoutes)/settings/admin/users/page.tsx b/app/(loggedInRoutes)/settings/admin/users/page.tsx index 4f304f699..84980a11e 100644 --- a/app/(loggedInRoutes)/settings/admin/users/page.tsx +++ b/app/(loggedInRoutes)/settings/admin/users/page.tsx @@ -1,6 +1,6 @@ import { AdminUsersClient } from "@/app/_components/FeatureComponents/Admin/Parts/AdminUsersClient"; -import { getAllLists } from "@/app/_server/actions/checklist"; -import { getAllNotes } from "@/app/_server/actions/note"; +import { getAllLists } from "@/app/_server/actions/checklist/queries"; +import { getAllNotes } from "@/app/_server/actions/note/queries"; import { isAdmin, getUsername, getUsersForAdmin } from "@/app/_server/actions/users"; import { notFound } from "next/navigation"; diff --git a/app/(loggedInRoutes)/settings/connections/page.tsx b/app/(loggedInRoutes)/settings/connections/page.tsx index db0bd01f2..51383bd37 100644 --- a/app/(loggedInRoutes)/settings/connections/page.tsx +++ b/app/(loggedInRoutes)/settings/connections/page.tsx @@ -1,24 +1,9 @@ -import { LinksTab } from "@/app/_components/FeatureComponents/Profile/Parts/LinksTab"; -import { readLinkIndex } from "@/app/_server/actions/link"; -import { getUsername } from "@/app/_server/actions/users"; -import { getArchivedItems } from "@/app/_server/actions/archived"; -import { getUserNotes } from "@/app/_server/actions/note"; -import { getUserChecklists } from "@/app/_server/actions/checklist"; -import { filterArchivedLinkIndex } from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data"; +import { permanentRedirect } from "next/navigation"; +import { BRAIN_PATH } from "@/app/_consts/relations"; -export default async function ConnectionsPage() { - const username = await getUsername(); - const [linkIndex, archivedResult, notesResult, checklistsResult] = await Promise.all([ - readLinkIndex(username), - getArchivedItems(), - getUserNotes({ username, metadataOnly: true }), - getUserChecklists({ username, metadataOnly: true }), - ]); - - const archivedItems = archivedResult.success ? archivedResult.data : []; - const filteredLinkIndex = filterArchivedLinkIndex(linkIndex, archivedItems || []); - const notes = notesResult.success ? notesResult.data || [] : []; - const checklists = checklistsResult.success ? checklistsResult.data || [] : []; - - return ; +/** + * Permanently redirect the retired connections settings route to the Brain. + */ +export default function ConnectionsPage() { + permanentRedirect(BRAIN_PATH); } diff --git a/app/(loggedInRoutes)/settings/sessions/page.tsx b/app/(loggedInRoutes)/settings/sessions/page.tsx index 55f8ca38e..f48dc1543 100644 --- a/app/(loggedInRoutes)/settings/sessions/page.tsx +++ b/app/(loggedInRoutes)/settings/sessions/page.tsx @@ -1,22 +1,14 @@ import { getTranslations } from "next-intl/server"; import { SessionsTabClient } from "@/app/_components/FeatureComponents/Profile/Parts/SessionsTabClient"; -import { getCurrentUser } from "@/app/_server/actions/users"; -import { getSessionsForUser, getSessionId } from "@/app/_server/actions/session"; +import { getMySessions } from "@/app/_server/actions/session"; export default async function SessionsPage() { const t = await getTranslations(); - const currentUser = await getCurrentUser(); - const sessionId = await getSessionId(); + const sessions = await getMySessions(); - if (!currentUser) { + if (!sessions) { return
{t('errors.unauthorized')}
; } - const sessionsData = await getSessionsForUser(currentUser.username); - const sessions = sessionsData?.map((session) => ({ - ...session, - isCurrent: session.id === sessionId, - })) || []; - return ; } diff --git a/app/(loggedInRoutes)/tasks/page.tsx b/app/(loggedInRoutes)/tasks/page.tsx index 48b6c6402..e328ba898 100644 --- a/app/(loggedInRoutes)/tasks/page.tsx +++ b/app/(loggedInRoutes)/tasks/page.tsx @@ -1,4 +1,4 @@ -import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { getUserChecklists } from "@/app/_server/actions/checklist/queries"; import { getCurrentUser } from "@/app/_server/actions/users"; import { TasksPageClient } from "@/app/_components/FeatureComponents/Checklists/TasksPageClient"; import { Checklist } from "@/app/_types"; diff --git a/app/_components/FeatureComponents/Admin/Parts/AdminAuditLogsClient.tsx b/app/_components/FeatureComponents/Admin/Parts/AdminAuditLogsClient.tsx index 24e051f6e..5df4e64e5 100644 --- a/app/_components/FeatureComponents/Admin/Parts/AdminAuditLogsClient.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/AdminAuditLogsClient.tsx @@ -14,12 +14,8 @@ import { } from "hugeicons-react"; import { Logo } from "@/app/_components/GlobalComponents/Layout/Logo/Logo"; import { useTranslations } from "next-intl"; -import { - getAuditLogs, - exportAuditLogs, - cleanupOldLogs, - deleteAllLogs, -} from "@/app/_server/actions/log"; +import { getAuditLogs, exportAuditLogs } from "@/app/_server/actions/log/readers"; +import { cleanupOldLogs, deleteAllLogs } from "@/app/_server/actions/log/cleanup"; import { useToast } from "@/app/_providers/ToastProvider"; import { DeleteAllLogsModal } from "@/app/_components/GlobalComponents/Modals/ConfirmationModals/DeleteAllLogsModal"; diff --git a/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx b/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx index d48b7e2cb..3194537de 100644 --- a/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx @@ -13,7 +13,7 @@ import { AdminContentColumn } from "./AdminContentColumn"; import { ExportContent } from "./AdminExport"; import { Accordion } from "@/app/_components/GlobalComponents/Layout/Accordion"; import { UserAvatar } from "@/app/_components/GlobalComponents/User/UserAvatar"; -import { rebuildLinkIndex } from "@/app/_server/actions/link"; +import { reindexRelations } from "@/app/_server/actions/relations"; import { updateTagsFromContent } from "@/app/_server/actions/tags"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { useTranslations } from "next-intl"; @@ -106,7 +106,8 @@ export const AdminContent = ({ const handleRebuildIndex = async (username: string) => { setRebuildingIndex(username); try { - await rebuildLinkIndex(username); + const result = await reindexRelations(username); + if (!result.success) throw new Error(result.error); showToast({ type: "success", title: t('common.success'), diff --git a/app/_components/FeatureComponents/Admin/Parts/AppSettingsTab.tsx b/app/_components/FeatureComponents/Admin/Parts/AppSettingsTab.tsx index 56cc2c207..84cee2ce6 100644 --- a/app/_components/FeatureComponents/Admin/Parts/AppSettingsTab.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/AppSettingsTab.tsx @@ -63,7 +63,12 @@ export const AppSettingsTab = () => { try { const formData = new FormData(); Object.entries(settings).forEach(([key, value]) => - formData.append(key, value) + formData.append( + key, + typeof value === "object" && value !== null + ? JSON.stringify(value) + : String(value) + ) ); const result = await updateAppSettings(formData); diff --git a/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx b/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx index 24ae1f753..e5a0d8e61 100644 --- a/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx @@ -253,15 +253,9 @@ export const EditorSettingsTab = () => { import("@nivo/network").then((mod) => mod.ResponsiveNetwork), - { ssr: false } -); - -export const SharingNetworkGraph = ({ data }: { data: any }) => { - const colors = useThemeColors(); - if (!data || data.nodes.length === 0) { - return ( -
- Not enough sharing data to display a network graph. -
- ); - } - - return ( -
- e.distance} - centeringStrength={0.3} - repulsivity={6} - nodeSize={(n: any) => n.size} - activeNodeSize={(n: any) => n.size * 1.5} - nodeColor={(e: any) => e.color} - nodeBorderWidth={1} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - linkThickness={2} - linkBlendMode="multiply" - motionConfig="wobbly" - /> -
- ); -}; diff --git a/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx b/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx deleted file mode 100644 index d41ea6794..000000000 --- a/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { useThemeColors } from "./Sharing/AdminSharingFunctions"; - -const ResponsiveNetwork = dynamic( - () => import("@nivo/network").then((mod) => mod.ResponsiveNetwork), - { ssr: false } -); - -export const SharingNetworkGraph = ({ data }: { data: any }) => { - const colors = useThemeColors(); - if (!data || data.nodes.length === 0) { - return ( -
- Not enough sharing data to display a network graph. -
- ); - } - - return ( -
- e.distance} - centeringStrength={0.3} - repulsivity={6} - nodeSize={(n: any) => n.size} - activeNodeSize={(n: any) => n.size * 1.5} - nodeColor={(e: any) => e.color} - nodeBorderWidth={1} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - linkThickness={2} - linkBlendMode="multiply" - motionConfig="wobbly" - /> -
- ); -}; diff --git a/app/_components/FeatureComponents/Brain/BrainPageClient.tsx b/app/_components/FeatureComponents/Brain/BrainPageClient.tsx new file mode 100644 index 000000000..033f2c59b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/BrainPageClient.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { AiBrain04Icon } from "hugeicons-react"; +import { Layout } from "@/app/_components/GlobalComponents/Layout/Layout"; +import { MobileHeader } from "@/app/_components/GlobalComponents/Layout/MobileHeader"; +import { IndexingRelations } from "@/app/_components/GlobalComponents/Layout/IndexingRelations"; +import { useShortcut } from "@/app/_providers/ShortcutsProvider"; +import { useToast } from "@/app/_providers/ToastProvider"; +import { connectItems } from "@/app/_server/actions/relations"; +import { createNote } from "@/app/_server/actions/note"; +import { BrainNodeKinds, RelationsStatus } from "@/app/_consts/relations"; +import { UNCATEGORIZED } from "@/app/_consts/notes"; +import { ItemTypes } from "@/app/_types/enums"; +import { itemHref } from "@/app/_utils/global-utils"; +import type { Category, SanitisedUser } from "@/app/_types"; +import type { BrainGraph, BrainNode } from "@/app/_types/relations"; +import { BrainToolbar } from "./Parts/BrainToolbar"; +import { BrainInspector } from "./Parts/BrainInspector"; +import { BrainLegend } from "./Parts/BrainLegend"; +import { useBrainPalette } from "./hooks/useBrainPalette"; +import { useBrainPrefs } from "./hooks/useBrainPrefs"; +import { BrainDimensions, brainView } from "./utils/brain-graph"; + +const BrainCanvas3D = dynamic(() => import("./Parts/BrainScene3D"), { + ssr: false, +}); +const BrainCanvas2D = dynamic(() => import("./Parts/BrainCanvas2D"), { + ssr: false, +}); + +interface BrainPageClientProps { + graph: BrainGraph; + focus: string | null; + isOwnBrain: boolean; + categories: Category[]; + user: SanitisedUser | null; +} + +const TAG_ROUTE = (tag: string) => `/?mode=tags&tag=${encodeURIComponent(tag)}`; + +export const BrainPageClient = ({ + graph, + focus, + isOwnBrain, + categories, + user, +}: BrainPageClientProps) => { + const t = useTranslations(); + const router = useRouter(); + const { showToast } = useToast(); + const { openSettings, openCreateNoteModal, openCreateCategoryModal } = + useShortcut(); + const palette = useBrainPalette(); + const focusId = + focus && graph.nodes.some((node) => node.id === focus) ? focus : null; + const prefs = useBrainPrefs(Boolean(focusId)); + const [selectedId, setSelectedId] = useState(focusId); + const [flyTo, setFlyTo] = useState<{ id: string; at: number } | null>(null); + + useEffect(() => setSelectedId(focusId), [focusId]); + + const view = useMemo( + () => brainView(graph, prefs.filters, prefs.scope, focusId), + [graph, prefs.filters, prefs.scope, focusId], + ); + const nodeIndex = useMemo( + () => new Map(graph.nodes.map((node) => [node.id, node])), + [graph.nodes], + ); + const selected = selectedId ? nodeIndex.get(selectedId) || null : null; + + const find = useCallback((id: string) => { + setSelectedId(id); + setFlyTo({ id, at: Date.now() }); + }, []); + + const open = useCallback( + async (node: BrainNode) => { + if (node.kind === BrainNodeKinds.NOTE) + return router.push(itemHref(ItemTypes.NOTE, node.id)); + if (node.kind === BrainNodeKinds.CHECKLIST) + return router.push(itemHref(ItemTypes.CHECKLIST, node.id)); + if (node.kind === BrainNodeKinds.TAG) + return router.push(TAG_ROUTE(node.title.replace(/^#/, ""))); + if (!isOwnBrain) return; + + const formData = new FormData(); + formData.append("title", node.title); + formData.append("category", UNCATEGORIZED); + formData.append("rawContent", ""); + const result = await createNote(formData); + if (result.success && result.data?.uuid) { + router.push(itemHref(ItemTypes.NOTE, result.data.uuid)); + return; + } + showToast({ + type: "error", + title: t("common.error"), + message: t("brain.createFailed"), + }); + }, + [router, isOwnBrain, showToast, t], + ); + + const connect = useCallback( + async (source: BrainNode, target: BrainNode) => { + const result = await connectItems(source.id, target.id); + if (!result.success) { + showToast({ + type: "error", + title: t("common.error"), + message: t("brain.connectFailed"), + }); + return; + } + showToast({ + type: "success", + title: t("common.success"), + message: t("brain.connected", { + source: source.title, + target: target.title, + }), + }); + router.refresh(); + }, + [router, showToast, t], + ); + + const Canvas = + prefs.dimension === BrainDimensions.THREE_D ? BrainCanvas3D : BrainCanvas2D; + const building = graph.status === RelationsStatus.BUILDING; + const empty = !building && graph.nodes.length === 0; + + return ( + +
+ + +
+ {building && } + + {empty && ( +
+ +

{t("brain.empty")}

+
+ )} + + {!building && !empty && ( + <> + + +
+
+ find(node.id)} + /> +
+
+ + + + {selected && ( +
+ setSelectedId(null)} + /> +
+ )} + + )} +
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx new file mode 100644 index 000000000..6e36a5c90 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { AiBrain04Icon } from "hugeicons-react"; +import { brainHref } from "@/app/_consts/relations"; +import { useAppMode } from "@/app/_providers/AppModeProvider"; +import { cn } from "@/app/_utils/global-utils"; + +interface BrainButtonProps { + uuid?: string; + className?: string; +} + +export const BrainButton = ({ uuid, className }: BrainButtonProps) => { + const t = useTranslations(); + const { appSettings } = useAppMode(); + + if (!uuid || appSettings?.editor?.enableBilateralLinks === false) return null; + + return ( + + + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx new file mode 100644 index 000000000..f0645326a --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph2D, { + ForceGraphMethods, + LinkObject, + NodeObject, +} from "react-force-graph-2d"; +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import { + edgeColour, + endpointId, + isItemNode, + neighbourMap, + nodeColour, + nodeRadius, + rgba, +} from "../utils/brain-graph"; +import { BrainCanvasProps, FLY_MS } from "../utils/brain-canvas"; +import { useElementSize } from "../hooks/useElementSize"; +import { collideForce, gravityForce, regionForce } from "../utils/brain-forces"; +import { paintHalo, paintStamp } from "../utils/brain-glyphs"; + +type CanvasNode = NodeObject & BrainNode; +type CanvasLink = LinkObject & BrainEdge; +type GraphRef = ForceGraphMethods; + +const FOCUS_ZOOM = 3; +const CLICK_SLOP_PX = 6; +const CLICK_MAX_MS = 450; +const DOUBLE_CLICK_MS = 350; +const REGION_PULL = 0.05; +const GRAVITY = 0.012; +const CHARGE = -110; +const CHARGE_REACH = 500; +const LINK_DISTANCE = 42; +const PERSONAL_SPACE = 5; +const LABEL_MAX_CHARS = 28; +const LABEL_FONT_PX = 12; +const LABEL_PAD_PX = 5; +const HUB_LABELS = 10; +const GLYPH_MIN_PX = 7; +const DIM_ALPHA = 0.15; +const FIT_PADDING = 120; +const STAMP_SCALE = 1.05; + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +const _overlaps = (a: Box, b: Box) => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +const _clip = (title: string) => + title.length > LABEL_MAX_CHARS ? `${title.slice(0, LABEL_MAX_CHARS - 1)}…` : title; + +/** + * Render an interactive 2D graph of items and their relationships. + * Apply focus, selection and palette settings, and report node selections and opens. + */ +export default function BrainCanvas2D({ + nodes, + edges, + focusId, + selectedId, + colourMode, + palette, + flyTo, + onSelect, + onOpen, +}: BrainCanvasProps) { + const graphRef = useRef(undefined); + const settledRef = useRef(false); + const { ref: containerRef, size } = useElementSize(); + const [hoverId, setHoverId] = useState(null); + const hoverRef = useRef(null); + const pressRef = useRef<{ x: number; y: number; at: number } | null>(null); + const lastClickRef = useRef({ id: "", at: 0 }); + + const graphData = useMemo( + () => ({ + nodes: nodes.map((node) => ({ ...node })) as CanvasNode[], + links: edges.map((edge) => ({ ...edge })) as CanvasLink[], + }), + [nodes, edges], + ); + + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + graph.d3Force("charge")?.strength?.(CHARGE)?.distanceMax?.(CHARGE_REACH); + graph.d3Force("link")?.distance?.(LINK_DISTANCE); + graph.d3Force("collide", collideForce((node) => nodeRadius(node, false) + PERSONAL_SPACE)); + graph.d3Force("region", regionForce(REGION_PULL)); + graph.d3Force("gravity", gravityForce(GRAVITY)); + }, [size.width, graphData]); + + const nodeAt = (event: React.PointerEvent): CanvasNode | null => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return null; + const rect = container.getBoundingClientRect(); + const point = graph.screen2GraphCoords(event.clientX - rect.left, event.clientY - rect.top); + let best: CanvasNode | null = null; + let bestDistance = Infinity; + graphData.nodes.forEach((candidate) => { + const distance = Math.hypot((candidate.x || 0) - point.x, (candidate.y || 0) - point.y); + const reach = Math.max(6, nodeRadius(candidate, false)) * 1.4; + if (distance <= reach && distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + }); + return best; + }; + + const onPointerDown = (event: React.PointerEvent) => { + pressRef.current = { x: event.clientX, y: event.clientY, at: Date.now() }; + }; + + const onPointerUp = (event: React.PointerEvent) => { + const press = pressRef.current; + pressRef.current = null; + if (!press || event.button !== 0) return; + const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y); + if (moved > CLICK_SLOP_PX || Date.now() - press.at > CLICK_MAX_MS) return; + + const node = hoverRef.current || nodeAt(event); + if (!node) { + onSelect(null); + return; + } + const now = Date.now(); + const last = lastClickRef.current; + lastClickRef.current = { id: node.id, at: now }; + if (last.id === node.id && now - last.at < DOUBLE_CLICK_MS) onOpen(node); + else onSelect(node.id); + }; + + const neighbours = useMemo(() => neighbourMap(edges, true), [edges]); + const hubs = useMemo( + () => + new Set( + [...nodes] + .filter(isItemNode) + .sort((a, b) => b.degree - a.degree) + .slice(0, HUB_LABELS) + .map((node) => node.id), + ), + [nodes], + ); + + const anchor = hoverId || selectedId || focusId; + const lit = useMemo( + () => + anchor + ? new Set([anchor, ...Array.from(neighbours.get(anchor) || [])]) + : null, + [anchor, neighbours], + ); + + const centreOn = useCallback( + (id: string) => { + const node = graphData.nodes.find((candidate) => candidate.id === id); + if (!node || node.x === undefined || node.y === undefined) return; + graphRef.current?.centerAt(node.x, node.y, FLY_MS); + graphRef.current?.zoom(FOCUS_ZOOM, FLY_MS); + }, + [graphData.nodes], + ); + + useEffect(() => { + if (flyTo) centreOn(flyTo.id); + }, [flyTo, centreOn]); + + const isLitLink = useCallback( + (link: CanvasLink) => + !lit || + (lit.has(endpointId(link.source)) && lit.has(endpointId(link.target))), + [lit], + ); + + const drawNode = useCallback( + (raw: object, ctx: CanvasRenderingContext2D, scale: number) => { + const node = raw as CanvasNode; + const isAnchor = node.id === focusId || node.id === selectedId; + const radius = nodeRadius(node, node.id === focusId) * STAMP_SCALE; + const x = node.x || 0; + const y = node.y || 0; + + ctx.globalAlpha = !lit || lit.has(node.id) ? 1 : DIM_ALPHA; + if (isAnchor || node.id === hoverId) { + paintHalo( + ctx, + x, + y, + radius * 1.55, + rgba(palette.primary, isAnchor ? 1 : 0.55), + Math.max(1.2 / scale, radius * 0.14), + ); + } + paintStamp( + ctx, + node, + x, + y, + radius, + { + fill: nodeColour(node, colourMode, palette), + edge: rgba(palette.background), + mark: rgba(palette.background), + }, + radius * scale >= GLYPH_MIN_PX, + ); + ctx.globalAlpha = 1; + }, + [focusId, selectedId, hoverId, lit, colourMode, palette], + ); + + const labelOrder = useMemo(() => { + const rank = (node: CanvasNode) => { + if (node.id === hoverId) return 0; + if (node.id === selectedId || node.id === focusId) return 1; + if (lit?.has(node.id)) return 2; + if (!lit && hubs.has(node.id)) return 3; + return -1; + }; + return graphData.nodes + .map((node) => ({ node, rank: rank(node) })) + .filter((entry) => entry.rank >= 0) + .sort((a, b) => a.rank - b.rank || b.node.degree - a.node.degree) + .map((entry) => entry.node); + }, [graphData.nodes, hoverId, selectedId, focusId, lit, hubs]); + + const drawLabels = useCallback( + (ctx: CanvasRenderingContext2D, scale: number) => { + const taken: Box[] = []; + const font = LABEL_FONT_PX / scale; + const pad = LABEL_PAD_PX / scale; + ctx.font = `600 ${font}px Inter, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + labelOrder.forEach((node) => { + const text = _clip(node.title); + const width = ctx.measureText(text).width + pad * 2; + const height = font + pad * 1.4; + const x = node.x || 0; + const top = (node.y || 0) + nodeRadius(node, node.id === focusId) * STAMP_SCALE * 1.7 + 2 / scale; + const box = { left: x - width / 2, top, right: x + width / 2, bottom: top + height }; + if (taken.some((other) => _overlaps(box, other))) return; + taken.push(box); + + ctx.beginPath(); + ctx.roundRect(box.left, box.top, width, height, height / 2); + ctx.fillStyle = rgba(palette.card); + ctx.fill(); + ctx.lineWidth = 1 / scale; + ctx.strokeStyle = rgba(palette.border); + ctx.stroke(); + ctx.fillStyle = rgba(palette.foreground); + ctx.fillText(text, x, box.top + height / 2); + }); + }, + [labelOrder, focusId, palette], + ); + + const paintHitArea = useCallback( + (raw: object, colour: string, ctx: CanvasRenderingContext2D) => { + const node = raw as CanvasNode; + ctx.fillStyle = colour; + ctx.beginPath(); + ctx.arc( + node.x || 0, + node.y || 0, + Math.max(6, nodeRadius(node, false)), + 0, + 2 * Math.PI, + ); + ctx.fill(); + }, + [], + ); + + return ( +
+ {size.width > 0 && ( + ""} + nodeCanvasObject={drawNode} + onRenderFramePost={drawLabels} + nodePointerAreaPaint={paintHitArea} + linkColor={(link) => + edgeColour( + link as CanvasLink, + palette, + isLitLink(link as CanvasLink), + ) + } + linkWidth={(link) => + lit && isLitLink(link as CanvasLink) ? 1.6 : 0.6 + } + linkVisibility={(link) => { + const canvasLink = link as CanvasLink; + if (canvasLink.kind !== BrainEdgeKinds.SUGGESTED) return true; + return Boolean(anchor) && + (endpointId(canvasLink.source) === anchor || endpointId(canvasLink.target) === anchor); + }} + linkLineDash={(link) => + (link as CanvasLink).kind === BrainEdgeKinds.SUGGESTED + ? [3, 3] + : null + } + linkDirectionalParticles={(link) => + lit && + isLitLink(link as CanvasLink) && + (link as CanvasLink).kind !== BrainEdgeKinds.TAG + ? 3 + : 0 + } + linkDirectionalParticleWidth={2} + linkDirectionalParticleColor={() => rgba(palette.primary, 0.95)} + onNodeHover={(node) => { + hoverRef.current = (node as CanvasNode | null) || null; + setHoverId(hoverRef.current?.id || null); + }} + onNodeRightClick={(node) => onOpen(node as CanvasNode)} + onEngineStop={() => { + if (settledRef.current) return; + settledRef.current = true; + if (focusId) centreOn(focusId); + else graphRef.current?.zoomToFit(600, FIT_PADDING); + }} + cooldownTicks={120} + /> + )} +
+ ); +} diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx new file mode 100644 index 000000000..dede5d182 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + CheckmarkSquare04Icon, + File02Icon, + FileAddIcon, + Link01Icon, + Tag01Icon, + Cancel01Icon, +} from "hugeicons-react"; +import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; + +interface BrainInspectorProps { + node: BrainNode | null; + nodes: Map; + edges: BrainEdge[]; + canEdit: boolean; + onPick: (id: string) => void; + onOpen: (node: BrainNode) => void; + onConnect: (source: BrainNode, target: BrainNode) => Promise; + onClose: () => void; +} + +const KindIcon = ({ node }: { node: BrainNode }) => { + if (node.kind === BrainNodeKinds.CHECKLIST) + return ; + if (node.kind === BrainNodeKinds.TAG) + return ; + if (node.kind === BrainNodeKinds.GHOST) + return ; + return ; +}; + +interface Neighbour { + node: BrainNode; + edge: BrainEdge; + outgoing: boolean; +} + +export const BrainInspector = ({ + node, + nodes, + edges, + canEdit, + onPick, + onOpen, + onConnect, + onClose, +}: BrainInspectorProps) => { + const t = useTranslations(); + const [connecting, setConnecting] = useState(null); + + const neighbours = useMemo(() => { + if (!node) return [] as Neighbour[]; + return edges + .filter((edge) => edge.source === node.id || edge.target === node.id) + .map((edge) => { + const outgoing = edge.source === node.id; + const other = nodes.get(outgoing ? edge.target : edge.source); + return other ? { node: other, edge, outgoing } : null; + }) + .filter((entry): entry is Neighbour => entry !== null); + }, [node, nodes, edges]); + + if (!node) return null; + + const suggested = neighbours + .filter((entry) => entry.edge.kind === BrainEdgeKinds.SUGGESTED) + .sort((a, b) => b.edge.weight - a.edge.weight); + const outgoing = neighbours.filter( + (entry) => entry.outgoing && entry.edge.kind !== BrainEdgeKinds.SUGGESTED, + ); + const incoming = neighbours.filter( + (entry) => !entry.outgoing && entry.edge.kind !== BrainEdgeKinds.SUGGESTED, + ); + const openable = + node.kind === BrainNodeKinds.NOTE || node.kind === BrainNodeKinds.CHECKLIST; + + const connect = async (other: BrainNode) => { + const [source, target] = + node.kind === BrainNodeKinds.NOTE ? [node, other] : [other, node]; + setConnecting(other.id); + try { + await onConnect(source, target); + } finally { + setConnecting(null); + } + }; + + const canConnect = (other: BrainNode) => + canEdit && + (node.kind === BrainNodeKinds.NOTE || other.kind === BrainNodeKinds.NOTE); + + const section = (title: string, entries: Neighbour[], action = false) => + entries.length === 0 ? null : ( +
+
+ {title} ({entries.length}) +
+ {entries.map(({ node: other, edge }) => ( +
+ + {action && canConnect(other) && ( + + )} +
+ ))} +
+ ); + + return ( + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx new file mode 100644 index 000000000..54e218ef6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { BrainEdgeKinds, BrainNodeKinds, LINK_EDGE_KINDS } from "@/app/_consts/relations"; +import type { BrainGraph } from "@/app/_types/relations"; +import { BrainPalette, edgeColour } from "../utils/brain-graph"; + +interface BrainLegendProps { + graph: BrainGraph; + palette: BrainPalette; + shown: number; + owner: string | null; +} + +const SWATCHES = [ + BrainEdgeKinds.LINK, + BrainEdgeKinds.MENTION, + BrainEdgeKinds.CHECKLIST, + BrainEdgeKinds.WIKI, + BrainEdgeKinds.SUGGESTED, + BrainEdgeKinds.TAG, +]; + +export const BrainLegend = ({ graph, palette, shown, owner }: BrainLegendProps) => { + const t = useTranslations(); + const items = graph.nodes.filter( + (node) => + node.kind === BrainNodeKinds.NOTE || + node.kind === BrainNodeKinds.CHECKLIST, + ).length; + const links = graph.edges.filter((edge) => LINK_EDGE_KINDS.has(edge.kind)).length; + + return ( +
+ {owner && ( +
+ {t("brain.viewingOwner", { owner })} +
+ )} +
{t("brain.summary", { items, links, shown })}
+
+ {SWATCHES.map((kind) => ( + + + {t(`brain.edges.${kind}`)} + + ))} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx new file mode 100644 index 000000000..f0fca66db --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph3D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-3d"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { BrainEdgeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import { + endpointId, + hex, + isItemNode, + neighbourMap, + nodeRadius, + nodeRgb, + rgba, + solidEdgeColour, +} from "../utils/brain-graph"; +import { + disposeParts, + forgetStamps, + LABEL_PIXEL_SHARE, + NodeParts, + nodeParts, + PartColours, + pickNode, + recolourParts, + tweenParts, +} from "../utils/brain-three"; +import { BrainCanvasProps } from "../utils/brain-canvas"; +import { useElementSize } from "../hooks/useElementSize"; +import { collideForce, gravityForce, regionForce, spreadRadius } from "../utils/brain-forces"; + +type CanvasNode = NodeObject & BrainNode; +type CanvasLink = LinkObject & BrainEdge; +type GraphRef = ForceGraphMethods; + +const TRANSPARENT = "rgba(0,0,0,0)"; +const DOUBLE_CLICK_MS = 350; +const CLICK_SLOP_PX = 6; +const CLICK_MAX_MS = 450; +const PAN_MS = 700; +const FRAME_MS = 900; +const FOCUS_DISTANCE = 110; +const MIN_FRAME_DISTANCE = 140; +const MAX_FRAME_DISTANCE = 1600; +const FRAME_SCALE = 2.3; +const SAFE_ZONE = 0.28; +const IDLE_ROTATE_MS = 25_000; +const AUTO_ROTATE_SPEED = 0.35; +const HUB_LABEL_SHARE = 8; +const MAX_HUB_LABELS = 8; +const CHARGE = -150; +const CHARGE_REACH = 600; +const LINK_DISTANCE = 58; +const REGION_PULL = 0.05; +const GRAVITY = 0.008; +const PERSONAL_SPACE = 7; +const DECLUTTER_EVERY = 6; +const LABEL_GAP_PX = 4; + +const noLabel = () => ""; +const orbitProps = { controlType: "orbit" } as Record; + +/** + * Render an interactive 3D graph of items and their relationships. + * Manage camera movement, node highlighting and labels, and release scene resources on cleanup. + */ +export default function BrainScene3D({ + nodes, + edges, + focusId, + selectedId, + colourMode, + palette, + flyTo, + onSelect, + onOpen, +}: BrainCanvasProps) { + const graphRef = useRef(undefined); + const partsRef = useRef(new Map()); + const framedRef = useRef(false); + const lastClickRef = useRef({ id: "", at: 0 }); + const idleTimerRef = useRef | null>(null); + const { ref: containerRef, size } = useElementSize(); + const [hoverId, setHoverId] = useState(null); + + const graphData = useMemo( + () => ({ + nodes: nodes.map((node) => ({ ...node })) as CanvasNode[], + links: edges.map((edge) => ({ ...edge })) as CanvasLink[], + }), + [nodes, edges], + ); + + const neighbours = useMemo(() => neighbourMap(edges, true), [edges]); + const hubs = useMemo(() => { + const count = Math.min(MAX_HUB_LABELS, Math.ceil(nodes.length / HUB_LABEL_SHARE)); + return new Set( + nodes + .filter(isItemNode) + .sort((a, b) => b.degree - a.degree) + .slice(0, count) + .map((node) => node.id), + ); + }, [nodes]); + + const anchor = hoverId || selectedId || focusId; + const lit = useMemo( + () => (anchor ? new Set([anchor, ...Array.from(neighbours.get(anchor) || [])]) : null), + [anchor, neighbours], + ); + + const aim = useCallback( + (parts: NodeParts, id: string) => { + const chosen = id === selectedId || id === focusId; + const shown = !lit || lit.has(id); + parts.target.body = shown ? 1 : 0.16; + parts.target.ring = chosen ? 0.9 : id === hoverId ? 0.45 : 0; + parts.target.label = + shown && (chosen || id === hoverId || Boolean(lit) || hubs.has(id)) ? 1 : 0; + }, + [selectedId, focusId, hoverId, lit, hubs], + ); + const aimRef = useRef(aim); + aimRef.current = aim; + + useEffect(() => { + partsRef.current.forEach(aim); + }, [aim]); + + const nodeByIdRef = useRef(new Map()); + nodeByIdRef.current = new Map(graphData.nodes.map((node) => [node.id, node])); + const priorityRef = useRef<(id: string) => number>(() => 0); + priorityRef.current = (id: string) => { + if (id === hoverId) return 0; + if (id === selectedId || id === focusId) return 1; + return 2; + }; + + const declutter = useCallback(() => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return; + const height = container.clientHeight * LABEL_PIXEL_SHARE; + const wanted: { parts: NodeParts; node: CanvasNode; x: number; y: number }[] = []; + + partsRef.current.forEach((parts, id) => { + const node = nodeByIdRef.current.get(id); + if (!node || parts.target.label === 0 || node.x === undefined) { + parts.crowded = false; + return; + } + const screen = graph.graph2ScreenCoords(node.x || 0, node.y || 0, node.z || 0); + wanted.push({ parts, node, x: screen.x, y: screen.y }); + }); + + wanted.sort( + (a, b) => + priorityRef.current(a.node.id) - priorityRef.current(b.node.id) || + b.node.degree - a.node.degree, + ); + + const taken: { left: number; right: number; top: number; bottom: number }[] = []; + wanted.forEach(({ parts, x, y }) => { + const width = height * parts.labelAspect; + const box = { + left: x - width / 2 - LABEL_GAP_PX, + right: x + width / 2 + LABEL_GAP_PX, + top: y - height * 1.6, + bottom: y - height * 0.2, + }; + parts.crowded = taken.some( + (other) => + box.left < other.right && other.left < box.right && box.top < other.bottom && other.top < box.bottom, + ); + if (!parts.crowded) taken.push(box); + }); + }, [containerRef]); + const declutterRef = useRef(declutter); + declutterRef.current = declutter; + + useEffect(() => { + let frame = 0; + let tick = 0; + const step = () => { + tick = (tick + 1) % DECLUTTER_EVERY; + if (tick === 0) declutterRef.current(); + partsRef.current.forEach(tweenParts); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + const parts = partsRef.current; + return () => { + cancelAnimationFrame(frame); + parts.forEach(disposeParts); + parts.clear(); + forgetStamps(); + }; + }, []); + + const controls = () => graphRef.current?.controls() as OrbitControls | undefined; + + const pauseRotation = useCallback(() => { + const orbit = controls(); + if (orbit) orbit.autoRotate = false; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => { + const idle = controls(); + if (idle) idle.autoRotate = true; + }, IDLE_ROTATE_MS); + }, []); + + useEffect(() => { + const orbit = controls(); + if (!orbit) return; + orbit.enableDamping = true; + orbit.dampingFactor = 0.12; + orbit.autoRotateSpeed = AUTO_ROTATE_SPEED; + orbit.addEventListener("start", pauseRotation); + return () => { + orbit.removeEventListener("start", pauseRotation); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, [size.width, pauseRotation]); + + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + graph.d3Force("charge")?.strength?.(CHARGE)?.distanceMax?.(CHARGE_REACH); + graph.d3Force("link")?.distance?.(LINK_DISTANCE); + graph.d3Force( + "collide", + collideForce((node) => nodeRadius(node, false) + PERSONAL_SPACE), + ); + graph.d3Force("region", regionForce(REGION_PULL)); + graph.d3Force("gravity", gravityForce(GRAVITY)); + }, [size.width, graphData]); + + const focusCamera = useCallback( + (id: string, closeUp: boolean) => { + const graph = graphRef.current; + const orbit = controls(); + const node = graphData.nodes.find((candidate) => candidate.id === id); + if (!graph || !orbit || !node || node.x === undefined) return; + + const point = { x: node.x || 0, y: node.y || 0, z: node.z || 0 }; + const offset = graph.camera().position.clone().sub(orbit.target); + + if (closeUp) { + offset.setLength(FOCUS_DISTANCE); + } else { + const screen = graph.graph2ScreenCoords(point.x, point.y, point.z); + const dx = Math.abs(screen.x / size.width - 0.5); + const dy = Math.abs(screen.y / size.height - 0.5); + if (dx < SAFE_ZONE && dy < SAFE_ZONE) return; + } + + pauseRotation(); + graph.cameraPosition( + { x: point.x + offset.x, y: point.y + offset.y, z: point.z + offset.z }, + point, + PAN_MS, + ); + }, + [graphData.nodes, size.width, size.height, pauseRotation], + ); + + useEffect(() => { + if (flyTo) focusCamera(flyTo.id, true); + }, [flyTo, focusCamera]); + + const frameGraph = useCallback(() => { + const graph = graphRef.current; + if (!graph) return; + const radius = spreadRadius(graphData.nodes); + const distance = Math.min( + MAX_FRAME_DISTANCE, + Math.max(MIN_FRAME_DISTANCE, radius * FRAME_SCALE), + ); + graph.cameraPosition({ x: 0, y: 0, z: distance }, { x: 0, y: 0, z: 0 }, FRAME_MS); + const orbit = controls(); + if (orbit) orbit.autoRotate = true; + }, [graphData.nodes]); + + const coloursFor = useCallback( + (node: BrainNode): PartColours => ({ + body: hex(nodeRgb(node, colourMode, palette)), + halo: rgba(palette.primary), + edge: rgba(palette.background), + mark: rgba(palette.background), + ink: rgba(palette.foreground), + paper: rgba(palette.card), + border: rgba(palette.border), + }), + [colourMode, palette], + ); + const coloursRef = useRef(coloursFor); + coloursRef.current = coloursFor; + const focusRef = useRef(focusId); + focusRef.current = focusId; + + const buildNode = useCallback((raw: object) => { + const node = raw as CanvasNode; + const previous = partsRef.current.get(node.id); + if (previous) disposeParts(previous); + const parts = nodeParts( + node, + nodeRadius(node, node.id === focusRef.current), + coloursRef.current(node), + ); + aimRef.current(parts, node.id); + partsRef.current.set(node.id, parts); + return parts.group; + }, []); + + useEffect(() => { + const ids = new Set(graphData.nodes.map((node) => node.id)); + partsRef.current.forEach((parts, id) => { + if (ids.has(id)) return; + disposeParts(parts); + partsRef.current.delete(id); + }); + }, [graphData.nodes]); + + useEffect(() => { + graphData.nodes.forEach((node) => { + const parts = partsRef.current.get(node.id); + if (parts) recolourParts(parts, node, coloursFor(node)); + }); + }, [coloursFor, graphData.nodes]); + + const touches = useCallback( + (link: CanvasLink) => + Boolean(anchor) && + (endpointId(link.source) === anchor || endpointId(link.target) === anchor), + [anchor], + ); + + const linkColour = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + if (!lit) return solidEdgeColour(canvasLink, palette, 0.35); + return solidEdgeColour(canvasLink, palette, touches(canvasLink) ? 0 : 0.85); + }, + [lit, palette, touches], + ); + + const linkWidth = useCallback( + (link: object) => (touches(link as CanvasLink) ? 0.9 : 0), + [touches], + ); + + const linkShown = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + return canvasLink.kind !== BrainEdgeKinds.SUGGESTED || touches(canvasLink); + }, + [touches], + ); + + const particles = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + return touches(canvasLink) && canvasLink.kind !== BrainEdgeKinds.TAG ? 2 : 0; + }, + [touches], + ); + + const particleColour = useCallback(() => rgba(palette.primary), [palette]); + + const pressRef = useRef<{ x: number; y: number; at: number } | null>(null); + + const handleClick = useCallback( + (node: CanvasNode) => { + const now = Date.now(); + const last = lastClickRef.current; + lastClickRef.current = { id: node.id, at: now }; + if (last.id === node.id && now - last.at < DOUBLE_CLICK_MS) { + onOpen(node); + return; + } + onSelect(node.id); + focusCamera(node.id, false); + }, + [onOpen, onSelect, focusCamera], + ); + + const onPointerDown = (event: React.PointerEvent) => { + pressRef.current = { x: event.clientX, y: event.clientY, at: Date.now() }; + }; + + const onPointerUp = (event: React.PointerEvent) => { + const press = pressRef.current; + pressRef.current = null; + const graph = graphRef.current; + const container = containerRef.current; + if (!press || !graph || !container || event.button !== 0) return; + const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y); + if (moved > CLICK_SLOP_PX || Date.now() - press.at > CLICK_MAX_MS) return; + + const rect = container.getBoundingClientRect(); + const id = pickNode( + partsRef.current, + graph.camera(), + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + const node = id ? graphData.nodes.find((candidate) => candidate.id === id) : null; + if (node) handleClick(node); + else onSelect(null); + }; + + return ( +
+ {size.width > 0 && ( + + (link as CanvasLink).kind === BrainEdgeKinds.SUGGESTED ? 0.25 : 0 + } + linkDirectionalParticles={particles} + linkDirectionalParticleWidth={1.3} + linkDirectionalParticleSpeed={0.006} + linkDirectionalParticleColor={particleColour} + enableNodeDrag={false} + onNodeHover={(node) => { + setHoverId((node as CanvasNode | null)?.id || null); + if (node) pauseRotation(); + }} + onNodeRightClick={(node) => onOpen(node as CanvasNode)} + onEngineStop={() => { + if (framedRef.current) return; + framedRef.current = true; + if (focusId) focusCamera(focusId, true); + else frameGraph(); + }} + warmupTicks={60} + cooldownTicks={160} + /> + )} +
+ ); +} diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx new file mode 100644 index 000000000..663e37d04 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + CubeIcon, + GridViewIcon, + Search01Icon, + Target02Icon, +} from "hugeicons-react"; +import type { BrainNode } from "@/app/_types/relations"; +import { + BrainColourModes, + BrainDimensions, + BrainFilters, + BrainScope, + MAX_DEPTH, + searchNodes, +} from "../utils/brain-graph"; + +interface BrainToolbarProps { + nodes: BrainNode[]; + dimension: BrainDimensions; + scope: BrainScope; + filters: BrainFilters; + colourMode: BrainColourModes; + hasFocus: boolean; + webglReady: boolean; + onDimension: (dimension: BrainDimensions) => void; + onScope: (scope: BrainScope) => void; + onFilters: (filters: BrainFilters) => void; + onColourMode: (mode: BrainColourModes) => void; + onFind: (node: BrainNode) => void; +} + +const FILTER_KEYS: Array = [ + "notes", + "checklists", + "tags", + "ghosts", + "suggestions", + "orphans", +]; + +const chip = (active: boolean) => + `rounded-full border px-3 py-1 text-md lg:text-xs transition-colors ${ + active + ? "border-primary bg-primary text-primary-foreground" + : "border-border bg-background text-muted-foreground hover:text-foreground" + }`; + +export const BrainToolbar = ({ + nodes, + dimension, + scope, + filters, + colourMode, + hasFocus, + webglReady, + onDimension, + onScope, + onFilters, + onColourMode, + onFind, +}: BrainToolbarProps) => { + const t = useTranslations(); + const [query, setQuery] = useState(""); + const matches = useMemo(() => searchNodes(nodes, query), [nodes, query]); + + return ( +
+
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && matches[0]) { + onFind(matches[0]); + setQuery(""); + } + }} + placeholder={t("brain.search")} + aria-label={t("brain.search")} + className="h-10 w-full rounded-jotty border border-input bg-background pl-8 pr-3 text-md lg:text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + {matches.length > 0 && ( +
    + {matches.map((node) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ {[BrainDimensions.THREE_D, BrainDimensions.TWO_D].map((option) => ( + + ))} +
+ + {hasFocus && ( + + )} + + {scope.local && hasFocus && ( + + )} + + +
+ +
+ {FILTER_KEYS.map((key) => ( + + ))} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts b/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts new file mode 100644 index 000000000..909b49c92 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { BrainPalette, Rgb } from "../utils/brain-graph"; + +const FALLBACK: BrainPalette = { + primary: [139, 59, 208], + foreground: [15, 23, 42], + muted: [100, 116, 139], + background: [255, 255, 255], + card: [255, 255, 255], + border: [226, 232, 240], +}; + +const _triple = ( + styles: CSSStyleDeclaration, + name: string, + fallback: Rgb, +): Rgb => { + const parts = styles + .getPropertyValue(name) + .trim() + .split(/[\s,]+/) + .map(Number) + .filter((value) => Number.isFinite(value)); + return parts.length >= 3 ? [parts[0], parts[1], parts[2]] : fallback; +}; + +const _read = (): BrainPalette => { + const styles = getComputedStyle(document.documentElement); + return { + primary: _triple(styles, "--primary", FALLBACK.primary), + foreground: _triple(styles, "--foreground", FALLBACK.foreground), + muted: _triple(styles, "--muted-foreground", FALLBACK.muted), + background: _triple(styles, "--background", FALLBACK.background), + card: _triple(styles, "--card", FALLBACK.card), + border: _triple(styles, "--border", FALLBACK.border), + }; +}; + +export const useBrainPalette = (): BrainPalette => { + const [palette, setPalette] = useState(FALLBACK); + + useEffect(() => { + const update = () => setPalette(_read()); + update(); + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "data-theme"], + }); + return () => observer.disconnect(); + }, []); + + return palette; +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts b/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts new file mode 100644 index 000000000..aefee22b6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BrainColourModes, + BrainDimensions, + BrainFilters, + BrainScope, + DEFAULT_FILTERS, +} from "../utils/brain-graph"; + +const STORAGE_KEY = "jotty-brain-prefs"; +const LOCAL_DEPTH = 2; + +interface StoredPrefs { + dimension?: BrainDimensions; + filters?: Partial; + colourMode?: BrainColourModes; + depth?: number; +} + +const _load = (): StoredPrefs => { + try { + return JSON.parse( + window.localStorage.getItem(STORAGE_KEY) || "{}", + ) as StoredPrefs; + } catch { + return {}; + } +}; + +const _save = (prefs: StoredPrefs) => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + return; + } +}; + +const _webgl = (): boolean => { + try { + const canvas = document.createElement("canvas"); + return Boolean(canvas.getContext("webgl2") || canvas.getContext("webgl")); + } catch { + return false; + } +}; + +const _prefersFlat = (): boolean => + window.matchMedia("(pointer: coarse)").matches || + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export const useBrainPrefs = (hasFocus: boolean) => { + const [dimension, setDimensionState] = useState(BrainDimensions.TWO_D); + const [filters, setFiltersState] = useState(DEFAULT_FILTERS); + const [colourMode, setColourModeState] = useState(BrainColourModes.TYPE); + const [scope, setScopeState] = useState({ + local: hasFocus, + depth: LOCAL_DEPTH, + }); + const [webglReady, setWebglReady] = useState(false); + + useEffect(() => { + const stored = _load(); + const ready = _webgl(); + setWebglReady(ready); + const wanted = + stored.dimension || + (_prefersFlat() ? BrainDimensions.TWO_D : BrainDimensions.THREE_D); + setDimensionState(ready ? wanted : BrainDimensions.TWO_D); + setFiltersState({ ...DEFAULT_FILTERS, ...stored.filters }); + if (stored.colourMode) setColourModeState(stored.colourMode); + if (stored.depth) + setScopeState((current) => ({ ...current, depth: stored.depth! })); + }, []); + + const persist = useCallback( + (patch: StoredPrefs) => _save({ ..._load(), ...patch }), + [], + ); + + return { + dimension, + filters, + colourMode, + scope, + webglReady, + setDimension: (next: BrainDimensions) => { + setDimensionState(next); + persist({ dimension: next }); + }, + setFilters: (next: BrainFilters) => { + setFiltersState(next); + persist({ filters: next }); + }, + setColourMode: (next: BrainColourModes) => { + setColourModeState(next); + persist({ colourMode: next }); + }, + setScope: (next: BrainScope) => { + setScopeState(next); + persist({ depth: next.depth }); + }, + }; +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts b/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts new file mode 100644 index 000000000..5e68deb3f --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export const useElementSize = () => { + const ref = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const update = () => { + const rect = element.getBoundingClientRect(); + setSize({ + width: Math.round(rect.width), + height: Math.round(rect.height), + }); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return { ref, size }; +}; diff --git a/app/_components/FeatureComponents/Brain/server/BrainPage.tsx b/app/_components/FeatureComponents/Brain/server/BrainPage.tsx new file mode 100644 index 000000000..51ac3c48b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/server/BrainPage.tsx @@ -0,0 +1,33 @@ +import { notFound, redirect } from "next/navigation"; +import { getBrain } from "@/app/_server/actions/relations"; +import { getCurrentUser } from "@/app/_server/actions/users"; +import { getCategories } from "@/app/_server/actions/category"; +import { Modes } from "@/app/_types/enums"; +import { isUuid } from "@/app/_consts/identity"; +import { BrainPageClient } from "../BrainPageClient"; + +interface BrainPageProps { + username?: string; + focus?: string; +} + +export const BrainPage = async ({ username, focus }: BrainPageProps) => { + const user = await getCurrentUser(); + if (!user?.username) redirect("/"); + + const [brain, categories] = await Promise.all([ + getBrain(username), + getCategories(Modes.NOTES), + ]); + if (!brain.success || !brain.data) notFound(); + + return ( + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts b/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts new file mode 100644 index 000000000..ea8ca254b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts @@ -0,0 +1,17 @@ +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import type { BrainColourModes, BrainPalette } from "./brain-graph"; + +export interface BrainCanvasProps { + nodes: BrainNode[]; + edges: BrainEdge[]; + focusId: string | null; + selectedId: string | null; + colourMode: BrainColourModes; + palette: BrainPalette; + flyTo: { id: string; at: number } | null; + onSelect: (id: string | null) => void; + onOpen: (node: BrainNode) => void; +} + +export const FLY_MS = 1100; +export const FLY_DISTANCE = 80; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-forces.ts b/app/_components/FeatureComponents/Brain/utils/brain-forces.ts new file mode 100644 index 000000000..79f4dbed6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-forces.ts @@ -0,0 +1,117 @@ +import type { BrainNode } from "@/app/_types/relations"; +import { isItemNode } from "./brain-graph"; + +interface SimNode extends BrainNode { + x?: number; + y?: number; + z?: number; + vx?: number; + vy?: number; + vz?: number; +} + +type Force = ((alpha: number) => void) & { initialize: (nodes: SimNode[]) => void }; + +const AXES = ["x", "y", "z"] as const; +const VELOCITY = { x: "vx", y: "vy", z: "vz" } as const; + +const _region = (node: SimNode): string | null => + isItemNode(node) ? node.category || null : null; + +const _force = (tick: (nodes: SimNode[], alpha: number) => void): Force => { + let nodes: SimNode[] = []; + const force = ((alpha: number) => tick(nodes, alpha)) as Force; + force.initialize = (next) => { + nodes = next; + }; + return force; +}; + +export const regionForce = (strength: number): Force => + _force((nodes, alpha) => { + const centres = new Map(); + nodes.forEach((node) => { + const region = _region(node); + if (!region) return; + const centre = centres.get(region) || { x: 0, y: 0, z: 0, count: 0 }; + centre.x += node.x || 0; + centre.y += node.y || 0; + centre.z += node.z || 0; + centre.count += 1; + centres.set(region, centre); + }); + + nodes.forEach((node) => { + const region = _region(node); + const centre = region ? centres.get(region) : undefined; + if (!centre || centre.count < 2) return; + AXES.forEach((axis) => { + const target = centre[axis] / centre.count; + const key = VELOCITY[axis]; + node[key] = (node[key] || 0) + (target - (node[axis] || 0)) * strength * alpha; + }); + }); + }); + +export const gravityForce = (strength: number): Force => + _force((nodes, alpha) => { + nodes.forEach((node) => { + AXES.forEach((axis) => { + const key = VELOCITY[axis]; + node[key] = (node[key] || 0) - (node[axis] || 0) * strength * alpha; + }); + }); + }); + +export const spreadRadius = (nodes: SimNode[], share = 0.9): number => { + const distances = nodes + .map((node) => Math.hypot(node.x || 0, node.y || 0, node.z || 0)) + .sort((a, b) => a - b); + if (distances.length === 0) return 0; + return distances[Math.min(distances.length - 1, Math.floor(distances.length * share))]; +}; + +const _cellKey = (x: number, y: number, z: number) => `${x},${y},${z}`; + +export const collideForce = (radiusOf: (node: SimNode) => number, strength = 0.7): Force => + _force((nodes) => { + const reach = nodes.reduce((max, node) => Math.max(max, radiusOf(node)), 1) * 2; + const grid = new Map(); + const cell = (value?: number) => Math.floor((value || 0) / reach); + + nodes.forEach((node) => { + const key = _cellKey(cell(node.x), cell(node.y), cell(node.z)); + const bucket = grid.get(key); + if (bucket) bucket.push(node); + else grid.set(key, [node]); + }); + + nodes.forEach((node) => { + const [cx, cy, cz] = [cell(node.x), cell(node.y), cell(node.z)]; + const mine = radiusOf(node); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + grid.get(_cellKey(cx + dx, cy + dy, cz + dz))?.forEach((other) => { + if (other === node || other.id > node.id) return; + const gap = mine + radiusOf(other); + const x = (node.x || 0) - (other.x || 0); + const y = (node.y || 0) - (other.y || 0); + const z = (node.z || 0) - (other.z || 0); + const distance = Math.hypot(x, y, z) || 0.01; + if (distance >= gap) return; + const push = ((gap - distance) / distance) * strength * 0.5; + node.vx = (node.vx || 0) + x * push; + node.vy = (node.vy || 0) + y * push; + other.vx = (other.vx || 0) - x * push; + other.vy = (other.vy || 0) - y * push; + if (node.z !== undefined) { + node.vz = (node.vz || 0) + z * push; + other.vz = (other.vz || 0) - z * push; + } + }); + } + } + } + }); + }); diff --git a/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts b/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts new file mode 100644 index 000000000..17b0306c0 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts @@ -0,0 +1,185 @@ +import type { ReactElement } from "react"; +import { + CheckmarkSquare04Icon, + File02Icon, + FileAddIcon, + Tag01Icon, + TaskDaily01Icon, +} from "hugeicons-react"; +import { BrainNodeKinds } from "@/app/_consts/relations"; +import { isKanbanType } from "@/app/_types/enums"; +import type { BrainNode } from "@/app/_types/relations"; + +export enum NodeShapes { + DISC = "disc", + TILE = "tile", + GEM = "gem", + HOLLOW = "hollow", +} + +export enum NodeGlyphs { + PAGE = "page", + TICK = "tick", + COLUMNS = "columns", + HASH = "hash", + PLUS = "plus", +} + +export interface StampInk { + fill: string; + edge: string; + mark: string; +} + +const EDGE_SHARE = 0.16; +const TILE_CORNER = 0.34; +const ICON_SHARE = 1.3; + +export const shapeOf = (node: BrainNode): NodeShapes => { + if (node.kind === BrainNodeKinds.GHOST) return NodeShapes.HOLLOW; + if (node.kind === BrainNodeKinds.TAG) return NodeShapes.GEM; + if (node.kind === BrainNodeKinds.CHECKLIST) return NodeShapes.TILE; + return NodeShapes.DISC; +}; + +export const glyphOf = (node: BrainNode): NodeGlyphs => { + if (node.kind === BrainNodeKinds.GHOST) return NodeGlyphs.PLUS; + if (node.kind === BrainNodeKinds.TAG) return NodeGlyphs.HASH; + if (node.kind === BrainNodeKinds.CHECKLIST) { + return isKanbanType(node.checklistType) ? NodeGlyphs.COLUMNS : NodeGlyphs.TICK; + } + return NodeGlyphs.PAGE; +}; + +export const stampKey = (node: BrainNode, ink: StampInk): string => + [shapeOf(node), glyphOf(node), ink.fill, ink.edge, ink.mark].join("|"); + +const _roundRect = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, corner: number) => { + const half = size / 2; + ctx.beginPath(); + ctx.moveTo(x - half + corner, y - half); + ctx.arcTo(x + half, y - half, x + half, y + half, corner); + ctx.arcTo(x + half, y + half, x - half, y + half, corner); + ctx.arcTo(x - half, y + half, x - half, y - half, corner); + ctx.arcTo(x - half, y - half, x + half, y - half, corner); + ctx.closePath(); +}; + +const _outline = (ctx: CanvasRenderingContext2D, shape: NodeShapes, x: number, y: number, r: number) => { + if (shape === NodeShapes.TILE) { + _roundRect(ctx, x, y, r * 1.8, r * TILE_CORNER * 1.8); + return; + } + if (shape === NodeShapes.GEM) { + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x + r, y); + ctx.lineTo(x, y + r); + ctx.lineTo(x - r, y); + ctx.closePath(); + return; + } + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); +}; + +type IconChild = ReactElement<{ d?: string }> | IconChild[] | null | undefined; + +type IconRender = { + render: (props: object, ref: null) => ReactElement<{ children?: IconChild }>; +}; + +const _flatten = (child: IconChild): ReactElement<{ d?: string }>[] => + Array.isArray(child) ? child.flatMap(_flatten) : child ? [child] : []; + +const GLYPH_ICONS: Record = { + [NodeGlyphs.PAGE]: File02Icon, + [NodeGlyphs.TICK]: CheckmarkSquare04Icon, + [NodeGlyphs.COLUMNS]: TaskDaily01Icon, + [NodeGlyphs.HASH]: Tag01Icon, + [NodeGlyphs.PLUS]: FileAddIcon, +}; + +const ICON_BOX = 24; +const ICON_STROKE = 1.9; +const iconPaths = new Map(); + +const _pathsOf = (glyph: NodeGlyphs): Path2D[] => { + const cached = iconPaths.get(glyph); + if (cached) return cached; + const svg = (GLYPH_ICONS[glyph] as IconRender).render({}, null); + const paths = _flatten(svg.props.children) + .map((child) => child.props.d) + .filter((d): d is string => Boolean(d)) + .map((d) => new Path2D(d)); + iconPaths.set(glyph, paths); + return paths; +}; + +const _glyph = (ctx: CanvasRenderingContext2D, glyph: NodeGlyphs, x: number, y: number, size: number) => { + ctx.save(); + ctx.translate(x - size / 2, y - size / 2); + ctx.scale(size / ICON_BOX, size / ICON_BOX); + ctx.lineWidth = ICON_STROKE; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + _pathsOf(glyph).forEach((path) => ctx.stroke(path)); + ctx.restore(); +}; + +export const paintStamp = ( + ctx: CanvasRenderingContext2D, + node: BrainNode, + x: number, + y: number, + r: number, + ink: StampInk, + withGlyph = true, +) => { + const shape = shapeOf(node); + const edge = r * EDGE_SHARE; + + if (shape === NodeShapes.HOLLOW) { + _outline(ctx, NodeShapes.DISC, x, y, r + edge / 2); + ctx.fillStyle = ink.edge; + ctx.fill(); + _outline(ctx, NodeShapes.DISC, x, y, r - edge / 2); + ctx.setLineDash([r * 0.45, r * 0.3]); + ctx.lineWidth = edge; + ctx.strokeStyle = ink.fill; + ctx.stroke(); + ctx.setLineDash([]); + if (withGlyph) { + ctx.strokeStyle = ink.fill; + _glyph(ctx, NodeGlyphs.PLUS, x, y, r * ICON_SHARE); + } + return; + } + + _outline(ctx, shape, x, y, r + edge); + ctx.fillStyle = ink.edge; + ctx.fill(); + _outline(ctx, shape, x, y, r); + ctx.fillStyle = ink.fill; + ctx.fill(); + + if (withGlyph) { + ctx.strokeStyle = ink.mark; + _glyph(ctx, glyphOf(node), x, y, r * (shape === NodeShapes.GEM ? ICON_SHARE * 0.8 : ICON_SHARE)); + } +}; + +export const paintHalo = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + colour: string, + width: number, +) => { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.lineWidth = width; + ctx.strokeStyle = colour; + ctx.stroke(); +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-graph.ts b/app/_components/FeatureComponents/Brain/utils/brain-graph.ts new file mode 100644 index 000000000..b718a5e6c --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-graph.ts @@ -0,0 +1,221 @@ +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainGraph, BrainNode } from "@/app/_types/relations"; + +export enum BrainDimensions { + THREE_D = "3d", + TWO_D = "2d", +} + +export enum BrainColourModes { + TYPE = "type", + CATEGORY = "category", +} + +export interface BrainFilters { + notes: boolean; + checklists: boolean; + tags: boolean; + ghosts: boolean; + suggestions: boolean; + orphans: boolean; +} + +export interface BrainScope { + local: boolean; + depth: number; +} + +export interface BrainView { + nodes: BrainNode[]; + edges: BrainEdge[]; +} + +export const DEFAULT_FILTERS: BrainFilters = { + notes: true, + checklists: true, + tags: false, + ghosts: true, + suggestions: true, + orphans: true, +}; + +export const MAX_DEPTH = 3; + +const KIND_FILTER: Record = { + [BrainNodeKinds.NOTE]: "notes", + [BrainNodeKinds.CHECKLIST]: "checklists", + [BrainNodeKinds.TAG]: "tags", + [BrainNodeKinds.GHOST]: "ghosts", +}; + +export const isItemNode = (node?: BrainNode | null): boolean => + node?.kind === BrainNodeKinds.NOTE || node?.kind === BrainNodeKinds.CHECKLIST; + +export const neighbourMap = (edges: BrainEdge[], withSuggested = false) => { + const map = new Map>(); + const add = (from: string, to: string) => { + if (!map.has(from)) map.set(from, new Set()); + map.get(from)!.add(to); + }; + edges.forEach((edge) => { + if (!withSuggested && edge.kind === BrainEdgeKinds.SUGGESTED) return; + add(edge.source, edge.target); + add(edge.target, edge.source); + }); + return map; +}; + +const _within = ( + focus: string, + neighbours: Map>, + depth: number, +) => { + const reached = new Set([focus]); + let frontier = [focus]; + for (let level = 0; level < depth; level++) { + const next: string[] = []; + frontier.forEach((id) => + neighbours.get(id)?.forEach((other) => { + if (reached.has(other)) return; + reached.add(other); + next.push(other); + }), + ); + frontier = next; + } + return reached; +}; + +export const brainView = ( + graph: BrainGraph, + filters: BrainFilters, + scope: BrainScope, + focus: string | null, +): BrainView => { + const edges = graph.edges.filter( + (edge) => + (filters.suggestions || edge.kind !== BrainEdgeKinds.SUGGESTED) && + (filters.tags || edge.kind !== BrainEdgeKinds.TAG), + ); + const linked = neighbourMap(edges); + const local = + scope.local && focus + ? _within(focus, neighbourMap(edges, true), scope.depth) + : null; + + const nodes = graph.nodes.filter((node) => { + if (!filters[KIND_FILTER[node.kind]]) return false; + if (local && !local.has(node.id)) return false; + if (!filters.orphans && !linked.has(node.id) && node.id !== focus) + return false; + return true; + }); + + const kept = new Set(nodes.map((node) => node.id)); + return { + nodes, + edges: edges.filter( + (edge) => kept.has(edge.source) && kept.has(edge.target), + ), + }; +}; + +export const searchNodes = ( + nodes: BrainNode[], + query: string, + limit = 8, +): BrainNode[] => { + const needle = query.trim().toLowerCase(); + if (!needle) return []; + return nodes + .filter((node) => node.title.toLowerCase().includes(needle)) + .sort((a, b) => b.degree - a.degree) + .slice(0, limit); +}; + +const _hue = (text: string): number => { + let hash = 0; + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return hash % 360; +}; + +export type Rgb = [number, number, number]; + +export interface BrainPalette { + primary: Rgb; + foreground: Rgb; + muted: Rgb; + background: Rgb; + card: Rgb; + border: Rgb; +} + +export const mix = (from: Rgb, to: Rgb, amount: number): Rgb => [ + Math.round(from[0] + (to[0] - from[0]) * amount), + Math.round(from[1] + (to[1] - from[1]) * amount), + Math.round(from[2] + (to[2] - from[2]) * amount), +]; + +export const rgba = ([r, g, b]: Rgb, alpha = 1): string => + `rgba(${r}, ${g}, ${b}, ${alpha})`; + +export const hex = ([r, g, b]: Rgb): string => + `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; + +const _categoryTint = (category: string, palette: BrainPalette): Rgb => { + const spread = (_hue(category) % 7) / 7; + return spread < 0.5 + ? mix(palette.primary, palette.foreground, spread * 0.9) + : mix(palette.primary, palette.background, (spread - 0.5) * 0.9); +}; + +export const nodeRgb = ( + node: BrainNode, + mode: BrainColourModes, + palette: BrainPalette, +): Rgb => { + if (node.kind === BrainNodeKinds.GHOST) return palette.muted; + if (node.kind === BrainNodeKinds.TAG) + return mix(palette.primary, palette.background, 0.45); + if (mode === BrainColourModes.CATEGORY) { + return _categoryTint(node.category || node.kind, palette); + } + return node.kind === BrainNodeKinds.CHECKLIST + ? mix(palette.primary, palette.foreground, 0.45) + : palette.primary; +}; + +export const nodeColour = ( + node: BrainNode, + mode: BrainColourModes, + palette: BrainPalette, +): string => rgba(nodeRgb(node, mode, palette)); + +export const edgeRgb = (edge: BrainEdge, palette: BrainPalette): Rgb => { + if (edge.kind === BrainEdgeKinds.SUGGESTED) return palette.muted; + if (edge.kind === BrainEdgeKinds.TAG) return mix(palette.primary, palette.background, 0.55); + if (edge.kind === BrainEdgeKinds.WIKI) return palette.primary; + if (edge.kind === BrainEdgeKinds.MENTION) return mix(palette.primary, palette.foreground, 0.5); + if (edge.kind === BrainEdgeKinds.CHECKLIST) return mix(palette.primary, palette.muted, 0.6); + return mix(palette.foreground, palette.background, 0.35); +}; + +export const edgeColour = (edge: BrainEdge, palette: BrainPalette, lit: boolean): string => + rgba(edgeRgb(edge, palette), lit ? 0.85 : 0.25); + +export const solidEdgeColour = (edge: BrainEdge, palette: BrainPalette, dim: number): string => + rgba(mix(edgeRgb(edge, palette), palette.background, dim)); + +export const nodeRadius = (node: BrainNode, emphasised: boolean): number => { + if (node.kind === BrainNodeKinds.TAG) return 2.2; + const base = Math.min(9, 3.2 + Math.sqrt(node.degree) * 1.1); + return emphasised ? base * 1.35 : base; +}; + +export const endpointId = (value: unknown): string => { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "id" in value) + return String((value as { id: unknown }).id); + return ""; +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-three.ts b/app/_components/FeatureComponents/Brain/utils/brain-three.ts new file mode 100644 index 000000000..8ea65daf2 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-three.ts @@ -0,0 +1,274 @@ +import * as THREE from "three"; +import type { BrainNode } from "@/app/_types/relations"; +import { paintStamp, stampKey, StampInk } from "./brain-glyphs"; + +export interface PaintTarget { + body: number; + ring: number; + label: number; +} + +export interface NodeParts { + group: THREE.Group; + body: THREE.Sprite; + hit: THREE.Mesh; + ring: THREE.Sprite; + label: THREE.Sprite; + labelAspect: number; + crowded: boolean; + target: PaintTarget; +} + +const LABEL_FONT_PX = 40; +const LABEL_PAD_X = 18; +const LABEL_PAD_Y = 10; +const LABEL_MAX_CHARS = 32; +const LABEL_SCREEN_HEIGHT = 0.026; +export const LABEL_PIXEL_SHARE = LABEL_SCREEN_HEIGHT * 1.35; +const LABEL_LIFT = 3; +const RING_TEXTURE_PX = 128; +const STAMP_TEXTURE_PX = 128; +const STAMP_PADDING = 1.25; +const TWEEN_RATE = 0.2; +const SETTLED = 0.004; +const HIT_SCALE = 1.8; +const HIT_MIN_EXTRA = 2.5; +const PICKABLE_OPACITY = 0.3; + +const _ignoreRaycast = () => undefined; + +const stamps = new Map(); + +const _stampTexture = (node: BrainNode, ink: StampInk): THREE.CanvasTexture => { + const key = stampKey(node, ink); + const cached = stamps.get(key); + if (cached) return cached; + + const canvas = document.createElement("canvas"); + canvas.width = STAMP_TEXTURE_PX; + canvas.height = STAMP_TEXTURE_PX; + const context = canvas.getContext("2d")!; + const centre = STAMP_TEXTURE_PX / 2; + paintStamp(context, node, centre, centre, centre / STAMP_PADDING / 1.16, ink); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + stamps.set(key, texture); + return texture; +}; + +export const forgetStamps = () => { + stamps.forEach((texture) => texture.dispose()); + stamps.clear(); +}; + +const _stampSprite = (node: BrainNode, radius: number, ink: StampInk): THREE.Sprite => { + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: _stampTexture(node, ink), + transparent: true, + alphaTest: 0.05, + opacity: 1, + depthWrite: true, + }), + ); + sprite.raycast = _ignoreRaycast; + sprite.renderOrder = 5; + const size = radius * 2 * STAMP_PADDING; + sprite.scale.set(size, size, 1); + return sprite; +}; + +const _pill = (context: CanvasRenderingContext2D, width: number, height: number) => { + const radius = height / 2; + context.beginPath(); + context.moveTo(radius, 0); + context.arcTo(width, 0, width, height, radius); + context.arcTo(width, height, 0, height, radius); + context.arcTo(0, height, 0, 0, radius); + context.arcTo(0, 0, width, 0, radius); + context.closePath(); +}; + +const _sprite = (canvas: HTMLCanvasElement, fixedSize = false): THREE.Sprite => { + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: false, + sizeAttenuation: !fixedSize, + }), + ); + sprite.raycast = _ignoreRaycast; + sprite.visible = false; + return sprite; +}; + +const _labelCanvas = (text: string, ink: string, paper: string, border: string): HTMLCanvasElement => { + const shown = text.length > LABEL_MAX_CHARS ? `${text.slice(0, LABEL_MAX_CHARS - 1)}…` : text; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + const font = `600 ${LABEL_FONT_PX}px Inter, system-ui, sans-serif`; + context.font = font; + canvas.width = Math.ceil(context.measureText(shown).width) + LABEL_PAD_X * 2; + canvas.height = LABEL_FONT_PX + LABEL_PAD_Y * 2; + + _pill(context, canvas.width, canvas.height); + context.fillStyle = paper; + context.fill(); + context.lineWidth = 3; + context.strokeStyle = border; + context.stroke(); + context.font = font; + context.textBaseline = "middle"; + context.fillStyle = ink; + context.fillText(shown, LABEL_PAD_X, canvas.height / 2 + 2); + return canvas; +}; + +const _labelSprite = (text: string, ink: string, paper: string, border: string): THREE.Sprite => { + const canvas = _labelCanvas(text, ink, paper, border); + const sprite = _sprite(canvas, true); + sprite.renderOrder = 20; + sprite.center.set(0.5, 0); + sprite.scale.set((LABEL_SCREEN_HEIGHT * canvas.width) / canvas.height, LABEL_SCREEN_HEIGHT, 1); + return sprite; +}; + +const _ringCanvas = (colour: string): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = RING_TEXTURE_PX; + canvas.height = RING_TEXTURE_PX; + const context = canvas.getContext("2d")!; + context.beginPath(); + context.arc(RING_TEXTURE_PX / 2, RING_TEXTURE_PX / 2, RING_TEXTURE_PX / 2 - 6, 0, Math.PI * 2); + context.lineWidth = 6; + context.strokeStyle = colour; + context.stroke(); + return canvas; +}; + +const _ringSprite = (colour: string, radius: number): THREE.Sprite => { + const sprite = _sprite(_ringCanvas(colour)); + sprite.renderOrder = 10; + sprite.scale.set(radius * 3, radius * 3, 1); + return sprite; +}; + +export interface PartColours { + body: string; + halo: string; + edge: string; + mark: string; + ink: string; + paper: string; + border: string; +} + +const _stampInk = (colours: PartColours): StampInk => ({ + fill: colours.body, + edge: colours.edge, + mark: colours.mark, +}); + +export const nodeParts = (node: BrainNode, radius: number, colours: PartColours): NodeParts => { + const group = new THREE.Group(); + const body = _stampSprite(node, radius, _stampInk(colours)); + + const hit = new THREE.Mesh( + new THREE.SphereGeometry(Math.max(radius * HIT_SCALE, radius + HIT_MIN_EXTRA), 12, 8), + new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false, colorWrite: false }), + ); + + hit.userData.nodeId = node.id; + + const ring = _ringSprite(colours.halo, radius); + const label = _labelSprite(node.title, colours.ink, colours.paper, colours.border); + label.position.set(0, radius + LABEL_LIFT, 0); + + group.add(body, hit, ring, label); + return { + group, + body, + hit, + ring, + label, + labelAspect: label.scale.x / label.scale.y, + crowded: false, + target: { body: 1, ring: 0, label: 0 }, + }; +}; + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +export const pickNode = ( + parts: Map, + camera: THREE.Camera, + ndcX: number, + ndcY: number, +): string | null => { + pointer.set(ndcX, ndcY); + raycaster.setFromCamera(pointer, camera); + const targets = Array.from(parts.values()) + .filter((part) => part.body.material.opacity > PICKABLE_OPACITY) + .map((part) => part.hit); + const [first] = raycaster.intersectObjects(targets, false); + return (first?.object.userData.nodeId as string | undefined) || null; +}; + +const _swapTexture = (sprite: THREE.Sprite, canvas: HTMLCanvasElement) => { + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + sprite.material.map?.dispose(); + sprite.material.map = texture; + sprite.material.needsUpdate = true; +}; + +export const recolourParts = (parts: NodeParts, node: BrainNode, colours: PartColours) => { + const title = node.title; + parts.body.material.map = _stampTexture(node, _stampInk(colours)); + parts.body.material.needsUpdate = true; + _swapTexture(parts.ring, _ringCanvas(colours.halo)); + const canvas = _labelCanvas(title, colours.ink, colours.paper, colours.border); + _swapTexture(parts.label, canvas); +}; + +const _toward = (current: number, target: number): [number, boolean] => { + const next = current + (target - current) * TWEEN_RATE; + return Math.abs(target - next) < SETTLED ? [target, false] : [next, true]; +}; + +export const tweenParts = (parts: NodeParts): boolean => { + const [body, bodyMoving] = _toward(parts.body.material.opacity, parts.target.body); + const [ring, ringMoving] = _toward(parts.ring.material.opacity, parts.target.ring); + const [label, labelMoving] = _toward( + parts.label.material.opacity, + parts.crowded ? 0 : parts.target.label, + ); + + parts.body.material.opacity = body; + parts.ring.material.opacity = ring; + parts.ring.visible = ring > SETTLED; + parts.label.material.opacity = label; + parts.label.visible = label > SETTLED; + + return bodyMoving || ringMoving || labelMoving; +}; + +export const disposeParts = (parts: NodeParts) => { + parts.body.material.dispose(); + parts.hit.geometry.dispose(); + parts.hit.material.dispose(); + [parts.ring, parts.label].forEach((sprite) => { + sprite.material.map?.dispose(); + sprite.material.dispose(); + }); +}; diff --git a/app/_components/FeatureComponents/Checklists/ChecklistsClient.tsx b/app/_components/FeatureComponents/Checklists/ChecklistsClient.tsx index a11c13937..73a1ddd09 100644 --- a/app/_components/FeatureComponents/Checklists/ChecklistsClient.tsx +++ b/app/_components/FeatureComponents/Checklists/ChecklistsClient.tsx @@ -142,7 +142,7 @@ export const ChecklistsClient = ({ currentLocale={user?.preferredLocale || "en"} /> -
+
{children}
diff --git a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx index edb8a04fc..26c92f503 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx @@ -21,6 +21,7 @@ import { toggleArchive } from "@/app/_server/actions/dashboard"; import { itemHref } from "@/app/_utils/global-utils"; import { ItemTypes } from "@/app/_types/enums"; import { useTranslations } from "next-intl"; +import { useReturnPath } from "@/app/_hooks/useReturnPath"; interface ChecklistClientProps { checklist: Checklist; @@ -48,6 +49,7 @@ export const ChecklistClient = ({ useState(""); const { openCreateChecklistModal, openCreateCategoryModal, openSettings } = useShortcut(); + const returnPath = useReturnPath(`/?mode=${Modes.CHECKLISTS}`); const prevChecklistId = useRef(checklist.id); const prevUpdatedAt = useRef(checklist.updatedAt); @@ -68,7 +70,7 @@ export const ChecklistClient = ({ const handleBack = () => { checkNavigation(() => { - router.push("/?mode=checklists"); + router.push(returnPath); }); }; @@ -106,7 +108,7 @@ export const ChecklistClient = ({ const handleDelete = (deletedId: string) => { checkNavigation(() => { - router.push("/?mode=checklists"); + router.push(returnPath); }); }; @@ -115,7 +117,7 @@ export const ChecklistClient = ({ getNewType, handleConfirmConversion, sensors, - DeleteModal, + deleteModal, } = useChecklist({ list: localChecklist, onUpdate: handleUpdate, @@ -239,7 +241,7 @@ export const ChecklistClient = ({ /> )} - + {deleteModal} {showCloneModal && ( + + {(permissions?.canEdit || permissions?.canDelete) && (
(null); const tagMentionsRef = useRef<{ onKeyDown: (event: KeyboardEvent) => boolean; focusSearch: () => void }>(null); const { user, tagsIndex, tagsEnabled } = useAppMode(); + const linkSuggestions = useItemLinkSuggestions(newItemText, setNewItemText, inputRef); const { showCompletedSuggestions: sessionShowCompletedSuggestions } = useSettings(); @@ -161,6 +164,7 @@ export const ChecklistHeading = ({ }; const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (linkSuggestions.handleKeyDown(e)) return; if (!showTagSuggestions || !tagMentionsRef.current) return; if (e.key === "Escape") { setShowTagSuggestions(false); @@ -300,7 +304,9 @@ export const ChecklistHeading = ({ />
)} - {showSuggestions && + + {!linkSuggestions.open && + showSuggestions && filteredSuggestions.length > 0 && newItemText.trim() !== "" && (
label.replace(/\\(.)/g, "$1"); + +const _hush = (event: SyntheticEvent) => event.stopPropagation(); + +const Inline = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +export const ChecklistItemText = ({ + text, + tagsEnabled = false, + compact = false, +}: ChecklistItemTextProps) => { + const { appSettings } = useAppMode(); + const linksEnabled = appSettings?.editor?.enableBilateralLinks !== false; + const parts: ReactNode[] = []; + let last = 0; + + for (const match of Array.from(text.matchAll(TOKENS))) { + const at = match.index ?? 0; + const [whole, label, href, wikiTarget, wikiLabel, tag] = match; + let piece: ReactNode = null; + + if (href !== undefined && linksEnabled) { + const target = parseItemHref(href, currentOrigins()); + if (target) { + piece = ( + + ); + } + } else if (wikiTarget !== undefined && linksEnabled) { + piece = ( + + ); + } else if (tag !== undefined && tagsEnabled) { + piece = ; + } + + if (!piece) continue; + if (at > last) parts.push(text.slice(last, at)); + parts.push( + + {piece} + , + ); + last = at + whole.length; + } + + if (last < text.length) parts.push(text.slice(last)); + return <>{parts.map((part, index) => {part})}; +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx index 8448e1d04..881ba176c 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx @@ -5,7 +5,7 @@ import { Checklist, ChecklistType } from "@/app/_types"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import type { JSX } from "react"; +import type { ReactNode } from "react"; interface ChecklistModalsProps { localList: Checklist; @@ -19,7 +19,7 @@ interface ChecklistModalsProps { getNewType: (type: ChecklistType) => ChecklistType; handleBulkPaste: (itemsText: string) => void; isLoading: boolean; - DeleteModal: () => JSX.Element; + deleteModal: ReactNode; } export const ChecklistModals = ({ @@ -34,7 +34,7 @@ export const ChecklistModals = ({ setShowBulkPasteModal, handleBulkPaste, isLoading, - DeleteModal, + deleteModal, }: ChecklistModalsProps) => { const router = useRouter(); const t = useTranslations(); @@ -71,7 +71,7 @@ export const ChecklistModals = ({ isLoading={isLoading} /> )} - + {deleteModal} ); }; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx new file mode 100644 index 000000000..e4d702c0e --- /dev/null +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { AtMentionsList } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/AtMentionsList"; +import type { ItemLinkSuggestions } from "@/app/_hooks/useItemLinkSuggestions"; + +export const ItemLinkPopup = ({ suggestions }: { suggestions: ItemLinkSuggestions }) => { + if (!suggestions.open) return null; + + return ( +
+ suggestions.pick(item as (typeof suggestions.items)[number])} + /> +
+ ); +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Simple/ChecklistBody.tsx b/app/_components/FeatureComponents/Checklists/Parts/Simple/ChecklistBody.tsx index 8a45d1996..025428161 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Simple/ChecklistBody.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Simple/ChecklistBody.tsx @@ -13,7 +13,6 @@ import { Checklist, Item } from "@/app/_types"; import { DropIndicator } from "./DropIndicator"; import { ItemTypes, TaskStatusLabels } from "@/app/_types/enums"; import { useMemo, useState } from "react"; -import { getReferences } from "@/app/_utils/indexes-utils"; import { useAppMode } from "@/app/_providers/AppModeProvider"; import { ReferencedBySection } from "@/app/_components/FeatureComponents/Notes/Parts/ReferencedBySection"; import { useTranslations } from "next-intl"; @@ -51,7 +50,6 @@ export const ChecklistBody = ({ isDeletingItem, }: ChecklistBodyProps) => { const t = useTranslations(); - const { linkIndex, notes, checklists, appSettings } = useAppMode(); const { isDragging, setIsDragging } = useUIStore(); const [activeItem, setActiveItem] = useState(null); const [overItem, setOverItem] = useState<{ @@ -59,16 +57,6 @@ export const ChecklistBody = ({ position: "before" | "after"; } | null>(null); - const referencingItems = useMemo(() => { - return getReferences( - linkIndex, - localList.uuid, - ItemTypes.CHECKLIST, - notes, - checklists - ); - }, [linkIndex, localList.uuid, notes, checklists]); - const onDragStart = (event: DragStartEvent) => { const findItem = (items: Item[], id: string): Item | undefined => { for (const item of items) { @@ -127,12 +115,7 @@ export const ChecklistBody = ({

- {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( -
- -
- )} + ); } @@ -142,7 +125,7 @@ export const ChecklistBody = ({ {localList.items.length > 0 && ( )} -
+
-
+
{incompleteItems.length > 0 && ( - {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( - - )} +
); diff --git a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx index 524cd7ecc..8a2119482 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx @@ -22,9 +22,11 @@ import { useShowEmojis } from "@/app/_hooks/useShowEmojis"; import { useEmojiCache } from "@/app/_hooks/useEmojiCache"; import { Checklist, Item } from "@/app/_types"; import { useAppMode } from "@/app/_providers/AppModeProvider"; -import { TagLinkViewComponent } from "@/app/_components/FeatureComponents/Tags/TagLinkComponent"; import { Input } from "@/app/_components/GlobalComponents/FormElements/Input"; import { useTagSuggestions } from "@/app/_hooks/useTagSuggestions"; +import { useItemLinkSuggestions } from "@/app/_hooks/useItemLinkSuggestions"; +import { ItemLinkPopup } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup"; +import { ChecklistItemText } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistItemText"; import { TagMentionsList } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/TagMentionsList"; import LastModifiedCreatedInfo from "../Common/LastModifiedCreatedInfo"; import { RecurrenceIndicator } from "@/app/_components/GlobalComponents/Indicators/RecurrenceIndicator"; @@ -33,6 +35,12 @@ import { useTranslations } from "next-intl"; import { Droppable } from "./Droppable"; import { DropIndicator } from "./DropIndicator"; import { useEditorActivityStore } from "@/app/_utils/editor-activity-store"; +import { useMenuPlacement } from "@/app/_hooks/useMenuPlacement"; +import { + MenuAlign, + MenuSide, + menuPlacementClasses, +} from "@/app/_utils/menu-placement-utils"; interface NestedChecklistItemProps { item: Item; @@ -44,6 +52,7 @@ interface NestedChecklistItemProps { onAddSubItem?: (parentId: string, text: string) => void; completed?: boolean; isPublicView?: boolean; + ownerShowsEmojis?: boolean; isDeletingItem: boolean; isDragDisabled?: boolean; isSubtask?: boolean; @@ -65,6 +74,7 @@ const NestedChecklistItemComponent = ({ onAddSubItem, completed = false, isPublicView = false, + ownerShowsEmojis, isDeletingItem, isDragDisabled = false, isSubtask = false, @@ -97,7 +107,7 @@ const NestedChecklistItemComponent = ({ }, }); - const showEmojis = useShowEmojis(); + const showEmojis = useShowEmojis(ownerShowsEmojis); const emoji = useEmojiCache(item.text, showEmojis); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(item.text); @@ -105,12 +115,18 @@ const NestedChecklistItemComponent = ({ const [showAddSubItem, setShowAddSubItem] = useState(false); const [newSubItemText, setNewSubItemText] = useState(""); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [dropdownOpenUpward, setDropdownOpenUpward] = useState(false); const [isActive, setIsActive] = useState(false); const inputRef = useRef(null); const addSubItemInputRef = useRef(null); const dropdownRef = useRef(null); - const dropdownButtonRef = useRef(null); + const dropdownMenuRef = useRef(null); + const dropdownPlacement = useMenuPlacement( + isDropdownOpen, + dropdownRef, + dropdownMenuRef, + MenuSide.Down, + MenuAlign.End, + ); const editorActivity = useEditorActivityStore(); @@ -124,6 +140,12 @@ const NestedChecklistItemComponent = ({ addSubItemInputRef, { tagsIndex, tagsEnabled: !!tagsEnabled } ); + const editLinkSuggestions = useItemLinkSuggestions(editText, setEditText, inputRef); + const subItemLinkSuggestions = useItemLinkSuggestions( + newSubItemText, + setNewSubItemText, + addSubItemInputRef, + ); useEffect(() => { const editorId = `checklist-item-${item.id}`; @@ -179,6 +201,7 @@ const NestedChecklistItemComponent = ({ }; const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (editLinkSuggestions.handleKeyDown(e)) return; if ( editTagSuggestions.showTagSuggestions && editTagSuggestions.tagMentionsRef.current @@ -208,37 +231,7 @@ const NestedChecklistItemComponent = ({ } }; - const handleDropdownToggle = () => { - if (!isDropdownOpen && dropdownButtonRef.current) { - const rect = dropdownButtonRef.current.getBoundingClientRect(); - - let scrollParent: HTMLElement | null = - dropdownButtonRef.current.parentElement; - while (scrollParent) { - if (scrollParent.classList.contains("checklist-todo-container")) { - break; - } - scrollParent = scrollParent.parentElement; - } - - let shouldOpenUpward = false; - - if (scrollParent) { - const containerRect = scrollParent.getBoundingClientRect(); - const containerStyle = window.getComputedStyle(scrollParent); - const paddingBottom = parseInt(containerStyle.paddingBottom) || 0; - - const actualSpaceBelow = - containerRect.bottom - rect.bottom - paddingBottom; - const threshold = 200; - - shouldOpenUpward = actualSpaceBelow < threshold; - } - - setDropdownOpenUpward(shouldOpenUpward); - } - setIsDropdownOpen(!isDropdownOpen); - }; + const handleDropdownToggle = () => setIsDropdownOpen(!isDropdownOpen); const handleDropdownAction = (actionId: string) => { setIsDropdownOpen(false); @@ -259,27 +252,6 @@ const NestedChecklistItemComponent = ({ const displayText = showEmojis ? `${emoji} ${cleanText}` : cleanText; const hasChildren = item.children && item.children.length > 0; - const renderTextWithHashtags = (text: string): React.ReactNode[] => { - const hashtagPattern = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - while ((match = hashtagPattern.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - parts.push( - e.stopPropagation()}> - - - ); - lastIndex = match.index + match[0].length; - } - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - return parts; - }; const isChild = level > 0; const dropdownOptions = [ @@ -412,6 +384,7 @@ const NestedChecklistItemComponent = ({ />
)} +
@@ -557,7 +530,6 @@ const NestedChecklistItemComponent = ({ {!isPublicView && (
+
+

+ {keyMissing ? t("noKeyYet") : t("apiKeyHint")} +

+
+ + setFilter(event.target.value)} + /> + + {groups.length === 0 &&

{t("noMatches")}

} + + {groups.map(([tag, operations]) => ( +
+

{tag}

+ {operations.map((operation) => ( + + ))} +
+ ))} +
+ ); +}; diff --git a/app/_components/FeatureComponents/Howto/ApiExplorer/OperationCard.tsx b/app/_components/FeatureComponents/Howto/ApiExplorer/OperationCard.tsx new file mode 100644 index 000000000..a58ed1979 --- /dev/null +++ b/app/_components/FeatureComponents/Howto/ApiExplorer/OperationCard.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { ArrowDown01Icon, ArrowRight01Icon } from "hugeicons-react"; +import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; +import { Input } from "@/app/_components/GlobalComponents/FormElements/Input"; +import { Textarea } from "@/app/_components/GlobalComponents/FormElements/Textarea"; +import { cn } from "@/app/_utils/global-utils"; +import { + ExplorerMethod, + ExplorerSpec, + Operation, + bodySchemaOf, + buildUrl, + resolve, + sampleOf, +} from "./api-explorer-utils"; +import { useTryIt } from "./useTryIt"; + +interface OperationCardProps { + spec: ExplorerSpec; + operation: Operation; + apiKey: string; + apiBase: string; +} + +const METHOD_TONES: Record = { + [ExplorerMethod.GET]: "bg-primary/10 text-primary", + [ExplorerMethod.DELETE]: "bg-destructive/10 text-destructive", +}; + +const DEFAULT_TONE = "bg-accent text-accent-foreground"; +const FIRST_ERROR_STATUS = 400; + +export const OperationCard = ({ spec, operation, apiKey, apiBase }: OperationCardProps) => { + const t = useTranslations("apiExplorer"); + const [isOpen, setIsOpen] = useState(false); + const [values, setValues] = useState>({}); + const bodySchema = bodySchemaOf(operation); + const [body, setBody] = useState(() => + bodySchema ? JSON.stringify(sampleOf(spec, bodySchema), null, 2) : "", + ); + const [showSchema, setShowSchema] = useState(false); + const { result, isSending, failure, send } = useTryIt(); + + const setValue = (name: string, value: string) => + setValues((current) => ({ ...current, [name]: value })); + + const handleSend = () => + send( + operation.method, + buildUrl(apiBase, operation.path, values, operation.parameters), + apiKey, + bodySchema ? body : undefined, + ); + + const Chevron = isOpen ? ArrowDown01Icon : ArrowRight01Icon; + + return ( +
+ + + {isOpen && ( +
+
+

{operation.summary}

+ {operation.description && ( +

{operation.description}

+ )} +

+ {operation.operationId} + {operation.isPublic && ` · ${t("noKeyNeeded")}`} +

+
+ + {operation.parameters.length > 0 && ( +
+ {operation.parameters.map((param) => ( + setValue(param.name, event.target.value)} + /> + ))} +
+ )} + + {bodySchema && ( +
+