diff --git a/.env.example b/.env.example index 6f43c74..979aab3 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,5 @@ OIDC_CLIENT_SECRET=changeme NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office # Rich text editor (TipTap + Hocuspocus — docs texte) NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab +# UltiAI (chemin proxy OpenWebUI — même origine) +NEXT_PUBLIC_AI_PUBLIC_PATH=/ai diff --git a/app/api/demo/richtext-save/route.ts b/app/api/demo/richtext-save/route.ts new file mode 100644 index 0000000..f5081ac --- /dev/null +++ b/app/api/demo/richtext-save/route.ts @@ -0,0 +1,11 @@ +/** + * Sauvegarde factice pour la démo publique UltiDocs. + * Zéro rétention : le corps de la requête est ignoré. + */ +export async function PUT() { + return new Response(null, { status: 204 }) +} + +export async function POST() { + return new Response(null, { status: 204 }) +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..96ad825 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { Sparkles } from "lucide-react" +import { AiChatIframe } from "@/components/ai/ai-chat-iframe" +import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries" + +export default function ChatPage() { + const { data: config, isLoading } = useAiConfig() + const { data: quota } = useAiQuota(Boolean(config?.enabled)) + + if (isLoading) { + return ( +
+ Chargement UltiAI… +
+ ) + } + + if (!config?.enabled) { + return ( +
+ +

+ UltiAI n'est pas activé. Activez le plugin dans l'administration. +

+
+ ) + } + + return ( +
+
+
+ + UltiAI +
+ {quota ? ( + + {quota.requests_remaining}/{quota.requests_limit} requêtes aujourd'hui + + ) : null} +
+ +
+ ) +} diff --git a/app/demo/docs/page.tsx b/app/demo/docs/page.tsx new file mode 100644 index 0000000..dc49cbf --- /dev/null +++ b/app/demo/docs/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next" +import { DemoDocsEditor } from "@/components/demo/demo-docs-editor" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = { + ...suitePageMetadata({ + app: "drive", + title: "Démo UltiDocs", + absoluteTitle: true, + description: + "Essayez l'éditeur de documents UltiDocs sans compte — démo interactive, zéro rétention.", + }), + robots: { index: false }, +} + +export default function DemoDocsPage() { + return +} diff --git a/app/demo/mail/page.tsx b/app/demo/mail/page.tsx new file mode 100644 index 0000000..00dc420 --- /dev/null +++ b/app/demo/mail/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next" +import { DemoMailApp } from "@/components/demo/demo-mail-app" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = { + ...suitePageMetadata({ + app: "mail", + title: "Démo Ultimail", + absoluteTitle: true, + description: + "Essayez la messagerie Ultimail sans compte — démo interactive, zéro rétention.", + }), + robots: { index: false }, +} + +export default function DemoMailPage() { + return +} diff --git a/app/drive/draw/[fileId]/edit/page.tsx b/app/drive/draw/[fileId]/edit/page.tsx new file mode 100644 index 0000000..9ac1133 --- /dev/null +++ b/app/drive/draw/[fileId]/edit/page.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useParams } from "next/navigation" +import { UltidrawEditor } from "@/components/drive/ultidraw-editor" +import { isDriveFileIdSegment } from "@/lib/drive/drive-url" + +export default function DriveDrawEditPage() { + const params = useParams() + const fileId = params.fileId as string + + if (!isDriveFileIdSegment(fileId)) { + return ( +
+ Identifiant de dessin invalide +
+ ) + } + + return +} diff --git a/app/drive/s/[token]/[[...path]]/page.tsx b/app/drive/s/[token]/[[...path]]/page.tsx index b96c2e6..4827446 100644 --- a/app/drive/s/[token]/[[...path]]/page.tsx +++ b/app/drive/s/[token]/[[...path]]/page.tsx @@ -1,25 +1,37 @@ "use client" -import { useParams } from "next/navigation" +import { useParams, useRouter } from "next/navigation" import { useEffect, useState } from "react" -import { Loader2, Lock } from "lucide-react" +import { Lock } from "lucide-react" import { PublicShareChrome, PublicShareViewPanel, } from "@/components/drive/public-share-view" +import { DocsLoadingSplash } from "@/components/drive/richtext/docs-loading-splash" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { usePublicShare } from "@/lib/api/hooks/use-public-share-queries" import { folderPathFromPublicSegments } from "@/lib/api/public-share" +import { + shouldOpenInOnlyOffice, + shouldOpenInRichTextEditor, + shouldOpenInUltidrawEditor, +} from "@/lib/drive/drive-preview" +import { sharePermCanEdit } from "@/lib/drive/drive-share-permissions" +import { buildPublicShareEditHref } from "@/lib/drive/public-share-url" +import { sharePathLooksLikeEditorFile } from "@/lib/drive/share-path-looks-like-editor" export default function PublicSharePage() { const params = useParams() + const router = useRouter() const token = String(params.token ?? "") const pathSegments = params.path as string[] | undefined const path = folderPathFromPublicSegments(pathSegments) + const pathHintsEditor = sharePathLooksLikeEditorFile(path) const [passwordInput, setPasswordInput] = useState("") const [password, setPassword] = useState(undefined) + const [redirectingToEditor, setRedirectingToEditor] = useState(false) const { data, isLoading, isError, error, refetch, isFetching } = usePublicShare( token, @@ -30,12 +42,48 @@ export default function PublicSharePage() { const needsPassword = isError && error instanceof Error && error.message === "password_required" + const file = data?.item_type === "file" ? data.file : null + const isEditorFile = Boolean( + file && + (shouldOpenInRichTextEditor(file) || + shouldOpenInUltidrawEditor(file) || + shouldOpenInOnlyOffice(file)) + ) + const showDocsSplash = + pathHintsEditor || redirectingToEditor || (Boolean(data) && isEditorFile) + useEffect(() => { if (password && typeof window !== "undefined") { sessionStorage.setItem(`public-share-pw:${token}`, password) } }, [password, token]) + useEffect(() => { + if (!file || !data || !isEditorFile) return + setRedirectingToEditor(true) + const canEdit = sharePermCanEdit(data.permissions ?? 1) + const returnTo = + typeof window !== "undefined" + ? window.location.pathname + window.location.search + : undefined + const editor = shouldOpenInUltidrawEditor(file) + ? "ultidraw" + : shouldOpenInRichTextEditor(file) + ? "richtext" + : "office" + router.replace( + buildPublicShareEditHref( + token, + file.path, + returnTo, + canEdit ? "edit" : "view", + file.name, + editor, + data.item_type + ) + ) + }, [data, file, isEditorFile, router, token]) + const submitPassword = (event: React.FormEvent) => { event.preventDefault() const trimmed = passwordInput.trim() @@ -43,12 +91,19 @@ export default function PublicSharePage() { setPassword(trimmed) } + if ( + showDocsSplash && + !needsPassword && + (isLoading || (isFetching && !data) || redirectingToEditor || isEditorFile) + ) { + const splashTitle = file?.name ?? path.split("/").filter(Boolean).pop() + return + } + return ( {isLoading || (isFetching && !data) ? ( -
- -
+ ) : needsPassword ? (
diff --git a/app/drive/s/[token]/edit/[[...path]]/page.tsx b/app/drive/s/[token]/edit/[[...path]]/page.tsx index ba4d716..e6e8f69 100644 --- a/app/drive/s/[token]/edit/[[...path]]/page.tsx +++ b/app/drive/s/[token]/edit/[[...path]]/page.tsx @@ -4,7 +4,8 @@ import { useParams, useSearchParams } from "next/navigation" import { useEffect, useState } from "react" import { PublicOfficeEditor } from "@/components/drive/public-office-editor" import { PublicRichTextEditor } from "@/components/drive/public-richtext-editor" -import { shouldOpenInRichTextEditor } from "@/lib/drive/drive-preview" +import { PublicUltidrawEditor } from "@/components/drive/public-ultidraw-editor" +import { shouldOpenInRichTextEditor, shouldOpenInUltidrawEditor } from "@/lib/drive/drive-preview" import { filePathFromPublicEditSegments, readPublicShareRootType } from "@/lib/drive/public-share-url" import type { PublicShareRootType } from "@/lib/drive/public-share-url" @@ -36,11 +37,27 @@ export default function PublicShareEditPage() { return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined }) + const fileName = fileDisplayName ?? filePath.split("/").pop() ?? "" + + const useUltidraw = + editorParam === "ultidraw" || shouldOpenInUltidrawEditor({ name: fileName }) + const useRichText = - editorParam === "richtext" || - shouldOpenInRichTextEditor({ - name: fileDisplayName ?? filePath.split("/").pop() ?? "", - }) + editorParam === "richtext" || shouldOpenInRichTextEditor({ name: fileName }) + + if (useUltidraw) { + return ( + + ) + } if (useRichText) { return ( diff --git a/app/globals.css b/app/globals.css index 5e77a14..0bdba42 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,8 @@ @import 'tw-animate-css'; @import '../styles/onlyoffice-theme.css'; @import '../styles/richtext-editor.css'; +@import '../styles/docs-print.css'; +@import '../styles/landing.css'; @custom-variant dark (&:is(.dark *)); @@ -416,6 +418,18 @@ body { } } +@keyframes splash-logo-spin { + 0% { + transform: rotate(0deg); + } + 14% { + transform: rotate(36deg); + } + 100% { + transform: rotate(360deg); + } +} + @keyframes splash-loader-progress { 0% { transform: translateX(-104%); diff --git a/app/login/page.tsx b/app/login/page.tsx index f294f0a..09a7d39 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -10,7 +10,6 @@ import { CardFooter, CardHeader, } from "@/components/ui/card" -import { UltiMailLogo } from "@/components/ultimail-logo" import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" import { cn } from "@/lib/utils" @@ -32,10 +31,23 @@ function LoginContent() {
- +
+ + + UltiSuite + +
- Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la - messagerie. + Connecte-toi avec ton compte Ulti (Authentik) pour accéder à ta + suite : mail, drive, contacts et IA. {error ? (

diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 25aaede..1e99781 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -45,6 +45,7 @@ import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-b import { useWebSocket } from "@/lib/api/ws" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" import { FilePreviewDialog } from "@/components/drive/file-preview-dialog" +import { AiChatPanel } from "@/components/ai/ai-chat-panel" const MAIL_SETTINGS_PATH = "/mail/settings" @@ -191,6 +192,7 @@ function MailAppInner() {

+
{!splitView ? ( @@ -13,5 +24,5 @@ export default async function Home({ if (mail && mail.length > 0) { redirect(`/mail/inbox/message/${encodeURIComponent(mail)}`) } - redirect("/mail/inbox") + return } diff --git a/components/admin/settings/admin-settings-section-view.tsx b/components/admin/settings/admin-settings-section-view.tsx index 995a442..0641248 100644 --- a/components/admin/settings/admin-settings-section-view.tsx +++ b/components/admin/settings/admin-settings-section-view.tsx @@ -20,6 +20,7 @@ import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud import { MailingSection } from "@/components/admin/settings/sections/mailing-section" import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section" import { RichtextSection } from "@/components/admin/settings/sections/richtext-section" +import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section" import { AuditSection } from "@/components/admin/settings/sections/audit-section" const SECTIONS: Record = { @@ -38,6 +39,7 @@ const SECTIONS: Record = { mailing: MailingSection, onlyoffice: OnlyofficeSection, richtext: RichtextSection, + "ai-assistant": AiAssistantSection, audit: AuditSection, } diff --git a/components/admin/settings/sections/ai-assistant-section.tsx b/components/admin/settings/sections/ai-assistant-section.tsx new file mode 100644 index 0000000..68f9cb2 --- /dev/null +++ b/components/admin/settings/sections/ai-assistant-section.tsx @@ -0,0 +1,99 @@ +"use client" + +import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" + +export function AiAssistantSection() { + const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant) + const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant) + const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant) + + const enabled = effective?.enabled ?? aiAssistant.enabled + + return ( + + + +
+
+ Assistant IA + + Chat standalone et panneaux contextuels mail/drive/contacts. + +
+ setAiAssistant({ enabled: v })} + /> +
+
+ +
+ + setAiAssistant({ public_path: e.target.value })} + placeholder="/ai" + /> +
+
+ + setAiAssistant({ openwebui_internal_url: e.target.value })} + placeholder="http://openwebui:8080" + /> +
+
+ + setAiAssistant({ default_model: e.target.value })} + placeholder="gpt-4o" + /> +
+
+ + setAiAssistant({ chat_nc_path: e.target.value })} + placeholder="/.ultimail/ai/chats" + /> +
+
+
+ +

+ Les panneaux mail/drive/contacts ne sauvegardent pas l'historique. +

+
+ setAiAssistant({ embed_default_temporary: v })} + /> +
+
+
+ +

+ Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur. +

+
+ setAiAssistant({ chat_sync_enabled: v })} + /> +
+
+
+
+ ) +} diff --git a/components/ai/ai-chat-iframe.tsx b/components/ai/ai-chat-iframe.tsx new file mode 100644 index 0000000..cadae40 --- /dev/null +++ b/components/ai/ai-chat-iframe.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" +import type { AiChatContext } from "@/lib/ai/chat-context" +import { buildEmbedSearchParams } from "@/lib/ai/chat-context" +import { useTheme } from "next-themes" + +type AiChatIframeProps = { + publicPath?: string + context: AiChatContext + className?: string +} + +export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) { + const iframeRef = useRef(null) + const { resolvedTheme } = useTheme() + const src = useMemo(() => { + const base = publicPath.replace(/\/$/, "") + const qs = buildEmbedSearchParams(context) + return qs ? `${base}/?${qs}` : `${base}/` + }, [publicPath, context]) + + useEffect(() => { + const iframe = iframeRef.current + if (!iframe?.contentWindow) return + iframe.contentWindow.postMessage( + { type: "ULTI_THEME", theme: resolvedTheme === "dark" ? "dark" : "light" }, + window.location.origin + ) + }, [resolvedTheme]) + + useEffect(() => { + const iframe = iframeRef.current + if (!iframe?.contentWindow) return + iframe.contentWindow.postMessage( + { + type: "ULTI_CONTEXT_UPDATE", + context, + systemPrompt: context.systemPromptExtra, + }, + window.location.origin + ) + }, [context]) + + return ( +