diff --git a/apps/client/package.json b/apps/client/package.json index cca0ef4be..d836d5db6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -64,6 +64,7 @@ "react-router-dom": "7.18.2", "semver": "7.7.4", "socket.io-client": "4.8.3", + "yet-another-react-lightbox": "^3.32.2", "zod": "4.3.6" }, "devDependencies": { diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..0033ccc70 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -294,6 +294,9 @@ "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", + "Media": "Media", + "Open image": "Open image", + "Open video": "Open video", "Align left": "Align left", "Align right": "Align right", "Align center": "Align center", diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 74692d916..2de76fe70 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false); export const showLinkMenuAtom = atom(false); +export type LightboxRequest = { + src: string; + type: "image" | "video"; +} | null; + +const initialLightboxRequest: LightboxRequest = null; +export const lightboxRequestAtom = atom(initialLightboxRequest); + // Current page's edit mode — initialized from the user's saved preference on // first load, can be toggled locally without persisting to the server. export const currentPageEditModeAtom = atom(PageEditMode.Edit); diff --git a/apps/client/src/features/editor/components/common/lightbox-view.tsx b/apps/client/src/features/editor/components/common/lightbox-view.tsx new file mode 100644 index 000000000..df8369c86 --- /dev/null +++ b/apps/client/src/features/editor/components/common/lightbox-view.tsx @@ -0,0 +1,129 @@ +import type { Editor } from "@tiptap/react"; +import Lightbox, { type Slide } from "yet-another-react-lightbox"; +import { getFileUrl } from "@/lib/config.ts"; +import "yet-another-react-lightbox/styles.css"; +import "yet-another-react-lightbox/plugins/captions.css"; +import Captions from "yet-another-react-lightbox/plugins/captions"; +import Download from "yet-another-react-lightbox/plugins/download"; +import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen"; +import Video from "yet-another-react-lightbox/plugins/video"; +import Zoom from "yet-another-react-lightbox/plugins/zoom"; +import { useMemo } from "react"; +import i18n from "@/i18n.ts"; +import { useTranslation } from "react-i18next"; + +type LightboxViewProps = { + editor: Editor; + open: boolean; + src: string; + type: "image" | "video"; + onClose: () => void; +}; + +function getVideoMimeType(src: string) { + const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase(); + + switch (extension) { + case "webm": + return "video/webm"; + case "ogv": + return "video/ogg"; + case "mov": + return "video/quicktime"; + case "m4v": + return "video/x-m4v"; + default: + return "video/mp4"; + } +} + +function getFilename(src: string) { + const filename = src.split(/[?#]/, 1)[0].split("/").pop(); + if (!filename) return i18n.t("Media"); + + try { + return decodeURIComponent(filename); + } catch { + return filename; + } +} + +function getMedia(rawSrc: string, type?: string, alt?: string): Slide { + const src = getFileUrl(rawSrc); + const filename = getFilename(rawSrc); + const caption = alt || filename; + + if (type === "video") { + return { + type: "video", + sources: [{ src, type: getVideoMimeType(rawSrc) }], + title: caption, + download: { url: src, filename }, + }; + } else { + return { + type: "image", + src, + alt: alt || undefined, + title: caption, + download: { url: src, filename }, + }; + } +} + +function getPageMedia(editor: Editor): Slide[] { + const media: Slide[] = []; + + editor.state.doc.descendants((node) => { + if (node.type.name !== "image" && node.type.name !== "video") return; + + const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : ""; + if (!rawSrc) return; + + media.push(getMedia(rawSrc, node.type.name, node.attrs.alt)); + }); + + return media; +} + +export default function LightboxView({ + editor, + open, + src, + type, + onClose, +}: LightboxViewProps) { + const { i18n: i18nInstance } = useTranslation(); + + const slides = useMemo( + () => getPageMedia(editor), + [editor, open, i18nInstance.language] + ); + + const index = useMemo(() => { + const idx = slides.findIndex((slide) => + type === "video" + ? "sources" in slide && slide.sources.some((s) => s.src === src) + : "src" in slide && slide.src === src + ); + return idx >= 0 ? idx : 0; + }, [slides, src, type]); + + return ( + = 0 ? index : 0} + slides={slides} + plugins={[Captions, Download, Fullscreen, Video, Zoom]} + captions={{ descriptionTextAlign: "center" }} + video={{ controls: true, playsInline: true }} + zoom={{ + scrollToZoom: true, + maxZoomPixelRatio: 4, + maxZoom: 4, + supports: ["video"], + }} + /> + ); +} diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 179deda1c..178b4057b 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -1,6 +1,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import React, { useCallback, useRef } from "react"; +import { useSetAtom } from "jotai"; import { Node as PMNode } from "@tiptap/pm/model"; import { isEditorReady } from "@docmost/editor-ext"; import { @@ -14,6 +15,7 @@ import { IconLayoutAlignLeft, IconLayoutAlignRight, IconDownload, + IconMaximize, IconRefresh, IconTrash, } from "@tabler/icons-react"; @@ -21,11 +23,13 @@ import { useTranslation } from "react-i18next"; import { getFileUrl } from "@/lib/config.ts"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; +import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms"; import classes from "../common/toolbar-menu.module.css"; export function ImageMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); const fileInputRef = useRef(null); + const setLightboxRequest = useSetAtom(lightboxRequestAtom); const editorState = useEditorState({ editor, @@ -165,6 +169,25 @@ export function ImageMenu({ editor }: EditorMenuProps) { altTextPanel ) : (
+ + + editorState?.src && + setLightboxRequest({ + src: getFileUrl(editorState.src), + type: "image", + }) + } + size="lg" + aria-label={t("Open image")} + variant="subtle" + > + + + + +
+ + + + editorState?.src && + setLightboxRequest({ + src: getFileUrl(editorState.src), + type: "video", + }) + } + size="lg" + aria-label={t("Open video")} + variant="subtle" + > + + + + +
+ )} + {editor && ( + setLightboxRequest(null)} + /> + )} {showCommentPopup && } {showReadOnlyCommentPopup && ( diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 85efb5ec4..828236aad 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -213,6 +213,10 @@ export class PersistenceExtension implements Extension { workspaceId: page.workspaceId, }); + await this.aiQueue.add(QueueJob.GENERATE_PAGE_EMBEDDINGS, { + pageId + }) + await this.enqueuePageHistory(page); } } diff --git a/docker-compose.yml b/docker-compose.yml index 606c229b1..15b760086 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,25 @@ services: + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + volumes: + - ~/.ollama:/root/.ollama docmost: image: docmost/docmost:latest depends_on: - db - redis environment: - APP_URL: 'http://localhost:3000' - APP_SECRET: 'REPLACE_WITH_LONG_SECRET' - DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost' - REDIS_URL: 'redis://redis:6379' + APP_URL: "http://localhost:3000" + APP_SECRET: "6ee0204759950f97cecfaf4ae7ce8ddbe2cd99707870015471ee632d90aa0df5" + DATABASE_URL: "postgresql://docmost:12345@db:5432/docmost" + REDIS_URL: "redis://redis:6379" + AI_DRIVER: "ollama" + OLLAMA_API_URL: "http://localhost:11434" + AI_EMBEDDING_MODEL: "nomic-embed-text" + AI_COMPLETION_MODEL: "qwen2.5-coder:7b" + AI_EMBEDDING_DIMENSION: "768" ports: - "3000:3000" restart: unless-stopped @@ -16,18 +27,27 @@ services: - docmost:/app/data/storage db: - image: postgres:18 + image: pgvector/pgvector:pg18-trixie + ports: + - "5432:5432" environment: POSTGRES_DB: docmost POSTGRES_USER: docmost - POSTGRES_PASSWORD: STRONG_DB_PASSWORD + POSTGRES_PASSWORD: 12345 restart: unless-stopped volumes: - db_data:/var/lib/postgresql redis: image: redis:8 - command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"] + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory-policy + - noeviction + ports: + - "6379:6379" restart: unless-stopped volumes: - redis_data:/data diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4dffce5b..dd13234d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,6 +397,9 @@ importers: socket.io-client: specifier: 4.8.3 version: 4.8.3(supports-color@10.2.2) + yet-another-react-lightbox: + specifier: ^3.32.2 + version: 3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zod: specifier: 4.3.6 version: 4.3.6 @@ -4991,6 +4994,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -6319,6 +6323,7 @@ packages: eslint@9.28.0: resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -10033,6 +10038,20 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} + yet-another-react-lightbox@3.32.2: + resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yjs@13.6.30: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -20744,6 +20763,14 @@ snapshots: dependencies: pend: 1.2.0 + yet-another-react-lightbox@3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + yjs@13.6.30: dependencies: lib0: 0.2.117