diff --git a/.env.example b/.env.example index ef6e193..de02158 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,6 @@ NEXT_PUBLIC_APP_URL=http://localhost # Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint OIDC_CLIENT_SECRET=changeme + +# OnlyOffice Document Server (UltiDrive editor) +NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office diff --git a/app/contacts/layout.tsx b/app/contacts/layout.tsx index 66c05ef..4890e0d 100644 --- a/app/contacts/layout.tsx +++ b/app/contacts/layout.tsx @@ -1,8 +1,11 @@ import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" -export const metadata: Metadata = { - title: "Contacts - Ultimail", -} +export const metadata: Metadata = suitePageMetadata({ + app: "contacts", + absoluteTitle: true, + title: "Contacts - Ulti Suite", +}) export default function ContactsLayout({ children, diff --git a/app/drive/(browser)/[[...segments]]/layout.tsx b/app/drive/(browser)/[[...segments]]/layout.tsx new file mode 100644 index 0000000..80c112c --- /dev/null +++ b/app/drive/(browser)/[[...segments]]/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next" +import { driveDocumentTitle, suitePageMetadata } from "@/lib/suite/page-metadata" + +type LayoutProps = { + children: React.ReactNode + params: Promise<{ segments?: string[] }> +} + +export async function generateMetadata({ params }: LayoutProps): Promise { + const { segments } = await params + return suitePageMetadata({ + app: "drive", + titleSegment: driveDocumentTitle(segments), + }) +} + +export default function DriveSegmentsLayout({ children }: LayoutProps) { + return children +} diff --git a/app/drive/(browser)/[[...segments]]/page.tsx b/app/drive/(browser)/[[...segments]]/page.tsx new file mode 100644 index 0000000..cb73ee2 --- /dev/null +++ b/app/drive/(browser)/[[...segments]]/page.tsx @@ -0,0 +1,271 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useParams, useSearchParams } from "next/navigation" +import { DriveHeader } from "@/components/drive/drive-header" +import { DriveMobileBottomBar } from "@/components/drive/drive-mobile-bottom-bar" +import { DriveBrowserChrome } from "@/components/drive/drive-browser-chrome" +import { FileBrowser } from "@/components/drive/file-browser" +import { DriveMarqueeSurface } from "@/components/drive/drive-marquee-surface" +import { DriveScrollEndSpacer } from "@/components/drive/drive-scroll-end-spacer" +import { parseDriveSegments, folderPathFromSegments } from "@/lib/drive/drive-url" +import { + type DriveSearchScope, + defaultDriveSearchScope, + fileBrowserViewForSearchScope, + parseDriveSearchParams, +} from "@/lib/drive/drive-search" +import { useDriveFilteredItems } from "@/lib/hooks/use-drive-filtered-items" +import { useDriveFiltersStore } from "@/lib/stores/drive-filters-store" +import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store" +import { + DRIVE_BROWSER_CARD_CLASS, + DRIVE_CARD_PAD_X, + DRIVE_CARD_SCROLL_PT, + DRIVE_MAIN_INSET_X, +} from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" +import { + useDriveList, + useDriveRecent, + useDriveSearch, + useDriveSharedWithMe, + useDriveStarred, + useDriveTrash, +} from "@/lib/api/hooks/use-drive-queries" + +export default function DriveBrowserPage() { + const params = useParams() + const urlSearchParams = useSearchParams() + const segments = params.segments as string[] | undefined + const route = useMemo(() => parseDriveSegments(segments), [segments]) + + const folderPath = folderPathFromSegments(route.pathSegments) + const contextView = + route.view === "shared" ? "shared" : route.view === "search" ? "files" : route.view + const fallbackScope = defaultDriveSearchScope( + route.view === "shared" ? "shared" : "files", + folderPath + ) + + const committedSearch = useMemo(() => { + if (route.view !== "search") return null + return parseDriveSearchParams(urlSearchParams, { + scope: fallbackScope, + folderPath, + }) + }, [route.view, urlSearchParams, fallbackScope, folderPath]) + + const [searchInput, setSearchInput] = useState("") + const [searchScope, setSearchScope] = useState(fallbackScope) + + useEffect(() => { + if (route.view === "search" && committedSearch) { + setSearchInput(committedSearch.query) + setSearchScope(committedSearch.scope) + } + }, [route.view, committedSearch?.query, committedSearch?.scope]) + + useEffect(() => { + if (route.view !== "search") { + setSearchScope(fallbackScope) + } + }, [route.view, fallbackScope]) + + const filters = useDriveFiltersStore() + const sortField = useDriveSettingsStore((s) => s.sortField) + const sortDir = useDriveSettingsStore((s) => s.sortDir) + const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement) + + const list = useDriveList(folderPath, route.page, "", route.view === "files") + const shared = useDriveSharedWithMe( + route.page, + "", + route.view === "shared" && route.pathSegments.length === 0 + ) + const sharedFolder = useDriveList( + folderPath, + route.page, + "", + route.view === "shared" && route.pathSegments.length > 0 + ) + const recent = useDriveRecent() + const starred = useDriveStarred(folderPath) + const trash = useDriveTrash() + const searchResults = useDriveSearch( + committedSearch?.query ?? "", + committedSearch?.scope ?? "all", + committedSearch?.scope === "folder" ? committedSearch.folderPath : "/", + route.page, + route.view === "search" && Boolean(committedSearch?.query) + ) + + const active = + route.view === "search" + ? searchResults + : route.view === "recent" + ? recent + : route.view === "starred" + ? starred + : route.view === "trash" + ? trash + : route.view === "shared" + ? route.pathSegments.length === 0 + ? shared + : sharedFolder + : list + + const files = active.data?.files ?? [] + + const filtersSnapshot = useMemo( + () => ({ + types: filters.types, + sources: filters.sources, + contactEmail: filters.contactEmail, + contactName: filters.contactName, + datePreset: filters.datePreset, + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + }), + [ + filters.types, + filters.sources, + filters.contactEmail, + filters.contactName, + filters.datePreset, + filters.dateFrom, + filters.dateTo, + ] + ) + + const browseWithSubtree = + route.view === "files" || + (route.view === "shared" && route.pathSegments.length > 0) + + const { filteredItems: filteredFiles, corpusLoading } = useDriveFilteredItems( + files, + filtersSnapshot, + { sortField, sortDir, folderPlacement }, + { + recursiveCorpus: browseWithSubtree, + scopePath: folderPath, + } + ) + + const isLoading = active.isLoading || corpusLoading + + const isTrash = route.view === "trash" + const isSearchView = route.view === "search" + const searchBrowserView = committedSearch + ? fileBrowserViewForSearchScope(committedSearch.scope) + : "files" + + const emptyMessage = isSearchView + ? committedSearch?.query + ? "Aucun résultat pour cette recherche." + : "Saisissez un terme de recherche." + : "Ce dossier est vide." + + return ( + <> + +
+
+ +
+ 0} + className="min-h-full" + > + {isLoading && ( +

+ Chargement… +

+ )} + {active.isError && ( +

+ {isSearchView + ? "Impossible de charger les résultats de recherche." + : "Impossible de charger les fichiers."} +

+ )} + {!isLoading && !active.isError && files.length === 0 && ( +

+ {emptyMessage} +

+ )} + {!isLoading && !active.isError && files.length > 0 && filteredFiles.length === 0 && ( +

+ Aucun élément ne correspond aux filtres. +

+ )} + {filteredFiles.length > 0 ? ( + + ) : null} + +
+
+
+
+ + + ) +} diff --git a/app/drive/(browser)/layout.tsx b/app/drive/(browser)/layout.tsx new file mode 100644 index 0000000..1474b1e --- /dev/null +++ b/app/drive/(browser)/layout.tsx @@ -0,0 +1,5 @@ +import { DriveAppShell } from "@/components/drive/drive-app-shell" + +export default function DriveBrowserLayout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/app/drive/edit/[fileId]/layout.tsx b/app/drive/edit/[fileId]/layout.tsx new file mode 100644 index 0000000..27b9fa0 --- /dev/null +++ b/app/drive/edit/[fileId]/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next" +import { displayFileName } from "@/lib/drive/display-file-name" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +type LayoutProps = { + children: React.ReactNode + params: Promise<{ fileId: string }> +} + +export async function generateMetadata({ params }: LayoutProps): Promise { + const { fileId } = await params + const name = displayFileName(decodeURIComponent(fileId)) + return suitePageMetadata({ + app: "drive", + titleSegment: name, + }) +} + +export default function EditLayout({ children }: LayoutProps) { + return <>{children} +} diff --git a/app/drive/edit/[fileId]/page.tsx b/app/drive/edit/[fileId]/page.tsx new file mode 100644 index 0000000..77ab74f --- /dev/null +++ b/app/drive/edit/[fileId]/page.tsx @@ -0,0 +1,12 @@ +"use client" + +import { useParams, useSearchParams } from "next/navigation" +import { OfficeEditor } from "@/components/drive/office-editor" + +export default function DriveEditPage() { + const params = useParams() + const searchParams = useSearchParams() + const filePath = decodeURIComponent(params.fileId as string) + const returnTo = searchParams.get("returnTo") + return +} diff --git a/app/drive/layout.tsx b/app/drive/layout.tsx new file mode 100644 index 0000000..7f90695 --- /dev/null +++ b/app/drive/layout.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ app: "drive" }) + +export default function DriveRootLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/drive/s/[token]/[[...path]]/page.tsx b/app/drive/s/[token]/[[...path]]/page.tsx new file mode 100644 index 0000000..b96c2e6 --- /dev/null +++ b/app/drive/s/[token]/[[...path]]/page.tsx @@ -0,0 +1,91 @@ +"use client" + +import { useParams } from "next/navigation" +import { useEffect, useState } from "react" +import { Loader2, Lock } from "lucide-react" +import { + PublicShareChrome, + PublicShareViewPanel, +} from "@/components/drive/public-share-view" +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" + +export default function PublicSharePage() { + const params = useParams() + const token = String(params.token ?? "") + const pathSegments = params.path as string[] | undefined + const path = folderPathFromPublicSegments(pathSegments) + + const [passwordInput, setPasswordInput] = useState("") + const [password, setPassword] = useState(undefined) + + const { data, isLoading, isError, error, refetch, isFetching } = usePublicShare( + token, + path, + password + ) + + const needsPassword = + isError && error instanceof Error && error.message === "password_required" + + useEffect(() => { + if (password && typeof window !== "undefined") { + sessionStorage.setItem(`public-share-pw:${token}`, password) + } + }, [password, token]) + + const submitPassword = (event: React.FormEvent) => { + event.preventDefault() + const trimmed = passwordInput.trim() + if (!trimmed) return + setPassword(trimmed) + } + + return ( + + {isLoading || (isFetching && !data) ? ( +
+ +
+ ) : needsPassword ? ( +
+
+
+ +

Lien protégé par mot de passe

+
+

+ Saisissez le mot de passe pour accéder à ce partage. +

+
+ setPasswordInput(e.target.value)} + /> + +
+
+
+ ) : isError || !data ? ( +
+

+ Partage indisponible +

+

+ Ce lien est expiré, révoqué ou incorrect. +

+ +
+ ) : ( + + )} +
+ ) +} diff --git a/app/drive/s/[token]/edit/[[...path]]/page.tsx b/app/drive/s/[token]/edit/[[...path]]/page.tsx new file mode 100644 index 0000000..be918c9 --- /dev/null +++ b/app/drive/s/[token]/edit/[[...path]]/page.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useParams, useSearchParams } from "next/navigation" +import { useState } from "react" +import { PublicOfficeEditor } from "@/components/drive/public-office-editor" +import { filePathFromPublicEditSegments } from "@/lib/drive/public-share-url" + +export default function PublicShareEditPage() { + const params = useParams() + const searchParams = useSearchParams() + const token = String(params.token ?? "") + const pathSegments = params.path as string[] | undefined + const filePath = filePathFromPublicEditSegments(token, pathSegments) + const returnTo = searchParams.get("returnTo") + const mode = searchParams.get("mode") === "view" ? "view" : "edit" + const [password] = useState(() => { + if (typeof window === "undefined") return undefined + return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined + }) + + return ( + + ) +} diff --git a/app/drive/s/layout.tsx b/app/drive/s/layout.tsx new file mode 100644 index 0000000..2cbfb0d --- /dev/null +++ b/app/drive/s/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ + app: "drive", + titleSegment: "Partage", +}) + +export default function PublicShareLayout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/globals.css b/app/globals.css index 790732c..945430c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@import '../styles/onlyoffice-theme.css'; @custom-variant dark (&:is(.dark *)); @@ -63,6 +64,11 @@ --mail-list-chip-text: #3c4043; --mail-list-chip-muted: #f1f3f4; --mail-row-checkbox-border: #c2c2c2; + --drive-canvas: var(--app-canvas); + --drive-sidebar-foreground: var(--mail-text); + --drive-surface: var(--mail-surface); + --drive-toolbar: var(--mail-surface-elevated); + --suite-surface-elevated: var(--mail-surface-elevated); } .dark { @@ -91,6 +97,11 @@ --mail-list-chip-text: #e8eaed; --mail-list-chip-muted: #3c4043; --mail-row-checkbox-border: #9aa0a6; + --drive-canvas: var(--app-canvas); + --drive-sidebar-foreground: var(--mail-text); + --drive-surface: var(--mail-surface); + --drive-toolbar: var(--mail-surface-elevated); + --suite-surface-elevated: var(--mail-surface-elevated); --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.145 0 0); @@ -186,6 +197,13 @@ --color-mail-surface: var(--mail-surface); --color-mail-surface-elevated: var(--mail-surface-elevated); --color-mail-surface-muted: var(--mail-surface-muted); + --color-mail-text: var(--mail-text); + --color-mail-text-strong: var(--mail-text-strong); + --color-mail-text-muted: var(--mail-text-muted); + --color-mail-active: var(--mail-active); + --color-mail-nav-selected: var(--mail-nav-selected); + --color-mail-nav-selected-fg: var(--mail-nav-selected-fg); + --color-mail-nav-hover: var(--mail-nav-hover); --color-mail-border: var(--mail-border); --color-mail-border-subtle: var(--mail-border-subtle); --color-mail-invitation: var(--mail-invitation); @@ -194,6 +212,9 @@ --color-mail-list-chip-text: var(--mail-list-chip-text); --color-mail-list-chip-muted: var(--mail-list-chip-muted); --color-mail-row-checkbox-border: var(--mail-row-checkbox-border); + --color-drive-canvas: var(--drive-canvas); + --color-drive-surface: var(--drive-surface); + --color-drive-sidebar-foreground: var(--drive-sidebar-foreground); } @layer base { @@ -633,6 +654,61 @@ html[data-mail-background]:not([data-mail-background='none']) background-color: color-mix(in srgb, var(--mail-surface) 88%, transparent) !important; } +/* Drive : pas de fond décoratif mail — surfaces opaques (carte arrondie + chrome). */ +html[data-mail-background]:not([data-mail-background='none']) [data-drive-app].ultimail-app { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-app-canvas) { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface, .bg-white) { + background-color: var(--mail-surface) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface-elevated) { + background-color: var(--mail-surface-elevated) !important; +} + +/* Contacts : pas de fond décoratif mail — surfaces opaques. */ +html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app].ultimail-app { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-app-canvas) { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface, .bg-white) { + background-color: var(--mail-surface) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-elevated) { + background-color: var(--mail-surface-elevated) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-muted) { + background-color: var(--mail-surface-muted) !important; +} + +/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ +html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) + [data-mail-settings-app] + [data-mail-settings-sidebar] { + background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) + [data-mail-settings-app] + :where([data-mail-settings-main]) { + background-color: var(--mail-surface) !important; +} + .ultimail-app { position: relative; isolation: isolate; @@ -744,6 +820,22 @@ html.dark[data-mail-background]:not([data-mail-background='none']) /* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */ html.dark .ultimail-app { color-scheme: dark; + /* Tokens shadcn → gris mail (cards réglages, popovers, champs). */ + --background: var(--app-canvas); + --foreground: var(--mail-text); + --card: var(--mail-surface-elevated); + --card-foreground: var(--mail-text); + --popover: var(--mail-surface-elevated); + --popover-foreground: var(--mail-text); + --secondary: var(--mail-surface-muted); + --secondary-foreground: var(--mail-text); + --muted: var(--mail-surface-muted); + --muted-foreground: var(--mail-text-muted); + --accent: var(--mail-nav-hover); + --accent-foreground: var(--mail-text); + --border: var(--mail-border-subtle); + --input: var(--mail-border-subtle); + --ring: var(--mail-border); } html.dark .ultimail-app :where(.bg-white) { @@ -767,7 +859,7 @@ html.dark .ultimail-app :where(.bg-\[\#e8f0fe\]) { background-color: var(--mail-active) !important; } -html.dark .ultimail-app :where([class*='bg-white/']) { +html.dark .ultimail-app :where(.bg-white\/80, .bg-white\/90, .bg-white\/95) { background-color: color-mix(in srgb, var(--mail-surface) 82%, transparent) !important; } @@ -831,6 +923,28 @@ html.dark [data-slot='menubar-content'] { border-color: var(--border) !important; } +/* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */ +html.dark [data-drive-menu-surface], +html.dark [data-contacts-menu-surface] { + background-color: var(--mail-surface-elevated) !important; + color: var(--mail-text) !important; + border-color: var(--mail-border-subtle) !important; +} + +html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item']:focus, +html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted], +html.dark [data-drive-menu-surface] [data-slot='context-menu-item']:focus, +html.dark [data-drive-menu-surface] [data-slot='context-menu-item'][data-highlighted], +html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item']:focus, +html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted], +html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger']:focus, +html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger'][data-state='open'], +html.dark [data-contacts-menu-surface] [data-slot='select-item']:focus, +html.dark [data-contacts-menu-surface] [data-slot='select-item'][data-highlighted] { + background-color: var(--mail-nav-hover) !important; + color: var(--mail-text) !important; +} + html.dark [data-slot='dropdown-menu-item']:focus, html.dark [data-slot='dropdown-menu-item'][data-highlighted], html.dark [data-slot='dropdown-menu-sub-trigger']:focus, @@ -999,3 +1113,18 @@ html.dark :where([data-contacts-panel] .hover\:bg-gray-100:hover, [data-contacts html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) { border-color: var(--border) !important; } + +/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */ +html.dark .ultimail-app :where(.bg-background) { + background-color: var(--mail-surface-muted) !important; +} + +html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg-muted\/40) { + background-color: color-mix(in srgb, var(--mail-surface-muted) 72%, transparent) !important; +} + +html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) { + background-color: var(--mail-surface-muted) !important; + border-color: var(--mail-border-subtle) !important; + color: var(--mail-text) !important; +} diff --git a/app/layout.tsx b/app/layout.tsx index 5147535..90baeb7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,15 +7,12 @@ import { FirstLaunchSplash } from '@/components/first-launch-splash' import { QueryProvider } from '@/lib/api/query-provider' import { AuthProvider } from '@/components/auth/auth-provider' import { MailToaster } from '@/components/gmail/mail-toaster' +import { suiteRootMetadata } from '@/lib/suite/page-metadata' const _geist = Geist({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] }); -export const metadata: Metadata = { - title: 'Ultimail', - description: 'Interface client mail Ultimail (clone UI) construite avec React', - generator: 'v0.app', -} +export const metadata: Metadata = suiteRootMetadata() /** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */ export const viewport: Viewport = { diff --git a/app/login/layout.tsx b/app/login/layout.tsx index b487835..2c556c6 100644 --- a/app/login/layout.tsx +++ b/app/login/layout.tsx @@ -1,4 +1,11 @@ import { LoginChrome } from "@/components/auth/login-chrome" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ + app: "suite", + title: "Connexion", +}) export default function LoginLayout({ children, diff --git a/app/mail/layout.tsx b/app/mail/layout.tsx index 2476dd0..aac000a 100644 --- a/app/mail/layout.tsx +++ b/app/mail/layout.tsx @@ -1,4 +1,12 @@ import { MailAppShell } from "./mail-app-shell" +import type { Metadata } from "next" +import { suitePageMetadata, MAIL_INBOX_DOCUMENT_TITLE } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ + app: "mail", + absoluteTitle: true, + title: MAIL_INBOX_DOCUMENT_TITLE, +}) export default function MailLayout({ children, diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 49b3643..25aaede 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -11,6 +11,8 @@ import { useIsXs } from "@/hooks/use-xs" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { useMailSplitView } from "@/hooks/use-mail-split-view" import { useMailRoute } from "@/hooks/use-mail-route" +import { parseSearchParams } from "@/lib/mail-search/search-params" +import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" @@ -27,6 +29,7 @@ import { ScheduledMailProvider } from "@/lib/scheduled-mail-context" import { ComposeModalManager } from "@/components/gmail/compose-modal" import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { mailNavVisitKey } from "@/lib/mail-folder-display" +import { MailDocumentTitle } from "@/components/gmail/mail-document-title" import { useMailStore } from "@/lib/stores/mail-store" import { useMailUiStore } from "@/lib/stores/mail-ui-store" import { DEFAULT_INBOX_TAB } from "@/lib/mail-url" @@ -41,6 +44,7 @@ import { MailSignaturesSync } from "@/components/gmail/mail-signatures-sync" import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge" import { useWebSocket } from "@/lib/api/ws" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" +import { FilePreviewDialog } from "@/components/drive/file-preview-dialog" const MAIL_SETTINGS_PATH = "/mail/settings" @@ -52,6 +56,10 @@ function MailAppInner() { const router = useRouter() const { route, navigateRoute, searchParams: currentSearchParams } = useMailRoute() + const activeSearchQuery = + route.folderId === "search" + ? searchParamsToDisplayQuery(parseSearchParams(currentSearchParams)) + : "" const isXs = useIsXs() const touchNav = useTouchNav() @@ -108,6 +116,7 @@ function MailAppInner() { }) }} > +
{!splitView ? (
@@ -191,14 +200,14 @@ function MailAppInner() { } xsViewChrome={xsViewChrome} onOpenSearch={() => setMobileSearchOpen(true)} - searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} + searchQuery={activeSearchQuery} onClearSearch={() => router.push("/mail/inbox")} /> ) : null} setMobileSearchOpen(false)} - initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} + initialQuery={activeSearchQuery} />
@@ -260,6 +269,7 @@ export function MailAppShell({ + diff --git a/app/mail/settings/layout.tsx b/app/mail/settings/layout.tsx index ecb2d50..b6ef61e 100644 --- a/app/mail/settings/layout.tsx +++ b/app/mail/settings/layout.tsx @@ -1,4 +1,11 @@ import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ + app: "mail", + title: "Réglages", +}) export default function MailSettingsRootLayout({ children, diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx index c1fcfcc..724d196 100644 --- a/components/auth/auth-provider.tsx +++ b/components/auth/auth-provider.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from "react" import { usePathname, useRouter } from "next/navigation" -import { useAuthStore } from "@/lib/api/auth-store" +import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store" import { isOidcConfigured } from "@/lib/auth/oidc-config" import type { PlatformUser } from "@/lib/auth/jwt-claims" @@ -11,6 +11,7 @@ const REFRESH_LEAD_MS = 5 * 60 * 1000 const REFRESH_CHECK_MS = 60 * 1000 function isPublicPath(pathname: string) { + if (pathname.startsWith("/drive/s/")) return true return PUBLIC_PREFIXES.some( (prefix) => pathname === prefix || pathname.startsWith(prefix) ) @@ -159,7 +160,10 @@ export function useAuthLogout() { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }) logout() if (typeof window !== "undefined") { - localStorage.removeItem("ultimail-auth") + localStorage.removeItem(AUTH_STORAGE_KEY) + for (const legacy of LEGACY_AUTH_KEYS) { + localStorage.removeItem(legacy) + } } router.replace("/login") } diff --git a/components/drive/breadcrumb-folder-menu.tsx b/components/drive/breadcrumb-folder-menu.tsx new file mode 100644 index 0000000..0bab976 --- /dev/null +++ b/components/drive/breadcrumb-folder-menu.tsx @@ -0,0 +1,247 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { MoreVertical } from "lucide-react" +import { toast } from "sonner" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog" +import { DriveNameDialog } from "@/components/drive/drive-name-dialog" +import { DriveFileMenuActions } from "@/components/drive/drive-file-menu-actions" +import { DRIVE_MENU_SURFACE_CLASS } from "@/components/drive/drive-file-context-menu" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { DriveFileInfo } from "@/lib/api/types" +import type { DriveView } from "@/lib/drive/drive-url" +import { buildDriveFolderHref } from "@/lib/drive/drive-url" +import { displayFileName } from "@/lib/drive/display-file-name" +import { resolveRenameName } from "@/lib/drive/drive-default-name" +import { guardDriveMenuPointer, stopDriveMenuBubble } from "@/lib/drive/drive-menu-guard" +import { useIsMobile } from "@/hooks/use-mobile" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DRIVE_MENU_BTN, DRIVE_MENU_BTN_ACTIVE } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +function breadcrumbFolderTarget(folderPath: string, name: string): DriveFileInfo { + return { + path: folderPath, + name, + type: "directory", + size: 0, + mime_type: "httpd/unix-directory", + last_modified: "", + etag: "", + is_favorite: false, + } +} + +export function BreadcrumbFolderMenu({ + view, + segments, + folderPath, + writable = true, + allowShare = true, + renameOpen, + onRenameOpenChange, + className, +}: { + view: Extract + segments: string[] + folderPath: string + writable?: boolean + allowShare?: boolean + renameOpen?: boolean + onRenameOpenChange?: (open: boolean) => void + className?: string +}) { + const isMobile = useIsMobile() + const router = useRouter() + const [dropdownOpen, setDropdownOpen] = useState(false) + const [sheetOpen, setSheetOpen] = useState(false) + const [internalRenameOpen, setInternalRenameOpen] = useState(false) + const [folderPickerMode, setFolderPickerMode] = useState(null) + const setSharePath = useDriveUIStore((s) => s.setSharePath) + const mutations = useDriveMutations() + + const folderName = segments[segments.length - 1] ?? "" + const folder = useMemo( + () => breadcrumbFolderTarget(folderPath, folderName), + [folderPath, folderName] + ) + const targets = useMemo(() => [folder], [folder]) + const label = displayFileName(folderName) + + const renameControlled = onRenameOpenChange != null + const renameDialogOpen = renameControlled ? (renameOpen ?? false) : internalRenameOpen + const setRenameDialogOpen = renameControlled ? onRenameOpenChange : setInternalRenameOpen + + const handleRename = async (input: string) => { + const newName = resolveRenameName(folder, input) + if (displayFileName(folder.name) === newName) return + try { + await mutations.rename.mutateAsync({ path: folder.path, new_name: newName }) + toast.success("Dossier renommé") + const parentSegments = segments.slice(0, -1) + router.push(buildDriveFolderHref(view, [...parentSegments, newName])) + } catch { + toast.error("Impossible de renommer ce dossier") + throw new Error("rename failed") + } + } + + const closeDropdown = useCallback(() => { + setDropdownOpen(false) + guardDriveMenuPointer() + }, []) + + const openRenameDialog = useCallback(() => { + guardDriveMenuPointer() + window.setTimeout(() => setRenameDialogOpen(true), 0) + }, [setRenameDialogOpen]) + + const openFolderPicker = (mode: DriveFolderPickerMode) => { + setSheetOpen(false) + closeDropdown() + window.setTimeout(() => setFolderPickerMode(mode), 0) + } + + const menuActionsProps = { + targets, + writable, + allowShare, + hideOpen: true, + onOpen: () => {}, + setSharePath, + mutations, + onRenameRequest: openRenameDialog, + onMoveRequest: writable ? () => openFolderPicker("move") : undefined, + onCopyRequest: writable ? () => openFolderPicker("copy") : undefined, + } + + const dialogs = ( + <> + + { + if (!next) setFolderPickerMode(null) + }} + mode={folderPickerMode ?? "move"} + sources={targets} + /> + + + Actions pour {label} + + Actions disponibles pour {label}. + +

+ {label} +

+
+ setSheetOpen(false)} + onRenameRequest={() => { + setSheetOpen(false) + openRenameDialog() + }} + /> +
+
+
+ + ) + + if (isMobile) { + return ( + <> + + {dialogs} + + ) + } + + return ( + <> + { + if (next) { + setDropdownOpen(true) + return + } + closeDropdown() + }} + > + + + + e.preventDefault()} + onPointerDown={(e) => stopDriveMenuBubble(e)} + onClick={(e) => e.stopPropagation()} + > + { + closeDropdown() + openRenameDialog() + }} + /> + + + {dialogs} + + ) +} diff --git a/components/drive/breadcrumb-nav.tsx b/components/drive/breadcrumb-nav.tsx new file mode 100644 index 0000000..cce6dff --- /dev/null +++ b/components/drive/breadcrumb-nav.tsx @@ -0,0 +1,119 @@ +"use client" + +import { Fragment, useState } from "react" +import Link from "next/link" +import { ChevronRight } from "lucide-react" +import { BreadcrumbFolderMenu } from "@/components/drive/breadcrumb-folder-menu" +import type { DriveView } from "@/lib/drive/drive-url" +import { buildDriveFolderHref, folderPathFromSegments } from "@/lib/drive/drive-url" +import { displayFileName } from "@/lib/drive/display-file-name" +import { cn } from "@/lib/utils" + +/** xs/sm: single line, intermediate crumbs clamp + ellipsis. */ +const MOBILE_INTERMEDIATE_CRUMB_CLASS = + "max-md:line-clamp-1 max-md:min-w-0 max-md:shrink max-md:overflow-hidden max-md:break-all max-md:[overflow-wrap:anywhere]" + +/** Shared line box so mixed font sizes stay vertically centered in the chrome row. */ +const CRUMB_LINE_CLASS = "leading-6 md:leading-7" + +export function BreadcrumbNav({ + view, + segments, + writable = true, +}: { + view: Extract + segments: string[] + /** When false, double-click rename is disabled (e.g. read-only share). */ + writable?: boolean +}) { + const [renameOpen, setRenameOpen] = useState(false) + + const rootLabel = view === "shared" ? "Partagés avec moi" : "Mon Drive" + const rootHref = view === "shared" ? "/drive/shared" : "/drive" + const folderPath = folderPathFromSegments(segments) + const canRenameCurrent = writable && segments.length > 0 + + const crumbs = [{ label: rootLabel, href: rootHref, segments: [] as string[] }] + for (let i = 0; i < segments.length; i++) { + const slice = segments.slice(0, i + 1) + crumbs.push({ + label: displayFileName(segments[i]), + href: buildDriveFolderHref(view, slice), + segments: slice, + }) + } + + return ( + <> + + + ) +} diff --git a/components/drive/drive-app-shell.tsx b/components/drive/drive-app-shell.tsx new file mode 100644 index 0000000..256643a --- /dev/null +++ b/components/drive/drive-app-shell.tsx @@ -0,0 +1,43 @@ +"use client" + +import { useEffect, useLayoutEffect, type ReactNode } from "react" +import { DriveSidebar } from "@/components/drive/drive-sidebar" +import { FilePreviewDialog } from "@/components/drive/file-preview-dialog" +import { ShareDialog } from "@/components/drive/share-dialog" +import { SuiteThemeShell } from "@/components/suite/suite-theme-shell" +import { useIsMobile } from "@/hooks/use-mobile" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" + +export function DriveAppShell({ children }: { children: ReactNode }) { + const isMobile = useIsMobile() + const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed) + const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed) + const sidebarOpen = !sidebarCollapsed + + useLayoutEffect(() => { + if (!isMobile) setSidebarCollapsed(false) + }, [isMobile, setSidebarCollapsed]) + + useEffect(() => { + if (isMobile) setSidebarCollapsed(true) + }, [isMobile, setSidebarCollapsed]) + + return ( + +
+ {isMobile && sidebarOpen && ( +
+
+ ) +} diff --git a/components/drive/drive-browser-chrome.tsx b/components/drive/drive-browser-chrome.tsx new file mode 100644 index 0000000..3ae711b --- /dev/null +++ b/components/drive/drive-browser-chrome.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useMemo } from "react" +import { toast } from "sonner" +import { BreadcrumbNav } from "@/components/drive/breadcrumb-nav" +import { DriveBulkToolbar } from "@/components/drive/drive-bulk-toolbar" +import { DriveFilterBar } from "@/components/drive/drive-filter-bar" +import { DriveSortMenu } from "@/components/drive/drive-sort-menu" +import { DriveViewModeToggle } from "@/components/drive/drive-view-mode-toggle" +import { DriveSearchBreadcrumb } from "@/components/drive/drive-search-breadcrumb" +import type { DriveFileInfo } from "@/lib/api/types" +import type { DriveSearchState } from "@/lib/drive/drive-search" +import type { DriveView } from "@/lib/drive/drive-url" +import { DRIVE_CARD_PAD_X, DRIVE_FILTER_CONTENT_GAP } from "@/lib/drive/drive-chrome-classes" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { cn } from "@/lib/utils" + +const VIEW_TITLES: Partial> = { + recent: "Récents", + starred: "Favoris", + trash: "Corbeille", +} + +export function DriveBrowserChrome({ + view, + segments, + isTrash, + items, + searchState, +}: { + view: DriveView + segments: string[] + isTrash?: boolean + items: DriveFileInfo[] + searchState?: DriveSearchState | null +}) { + const selectedPaths = useDriveUIStore((s) => s.selectedPaths) + const clearSelection = useDriveUIStore((s) => s.clearSelection) + const mutations = useDriveMutations() + const selectedTargets = useMemo( + () => items.filter((f) => selectedPaths.has(f.path)), + [items, selectedPaths] + ) + const showBulk = selectedTargets.length > 0 + const showBreadcrumb = view === "files" || view === "shared" + const showSearchBreadcrumb = view === "search" && searchState + const title = VIEW_TITLES[view] + const allowShare = view !== "shared" + const showEmptyTrash = view === "trash" && !showBulk && items.length > 0 + + const onEmptyTrash = async () => { + if (!window.confirm("Vider la corbeille ? Cette action est irréversible.")) return + try { + await mutations.emptyTrash.mutateAsync() + clearSelection() + toast.success("Corbeille vidée") + } catch { + toast.error("Impossible de vider la corbeille") + } + } + + return ( +
+
+
+ {showSearchBreadcrumb ? ( + + ) : showBreadcrumb ? ( + + ) : title ? ( +
+

+ {title} +

+ {showEmptyTrash ? ( + + ) : null} +
+ ) : null} +
+
+ + +
+
+ {showBulk ? ( + + ) : ( + + )} +
+ ) +} diff --git a/components/drive/drive-bulk-toolbar.tsx b/components/drive/drive-bulk-toolbar.tsx new file mode 100644 index 0000000..2979959 --- /dev/null +++ b/components/drive/drive-bulk-toolbar.tsx @@ -0,0 +1,351 @@ +"use client" + +import { useState } from "react" +import { + Copy, + Download, + FolderInput, + Link2, + MoreVertical, + Trash2, + Undo2, + UserPlus, + X, +} from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog" +import { + canShareDriveItem, + DriveFileMenuActions, +} from "@/components/drive/drive-file-menu-actions" +import { DRIVE_MENU_SURFACE_CLASS } from "@/components/drive/drive-file-context-menu" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import { downloadDriveFile } from "@/lib/api/drive-download" +import type { DriveFileInfo } from "@/lib/api/types" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes" +import { DRIVE_ICON_BTN } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" +import { driveTrashItemKey } from "@/lib/drive/drive-trash" + +function BulkIconButton({ + label, + onClick, + disabled, + children, + className, +}: { + label: string + onClick: () => void + disabled?: boolean + children: React.ReactNode + className?: string +}) { + return ( + + ) +} + +export function DriveBulkToolbar({ + targets, + isTrash, + allowShare = true, + allowMove = true, + allowCopy = true, + allowQuickLink = true, + allowDelete = true, + mutations: mutationsProp, + onDownloadBulk, +}: { + targets: DriveFileInfo[] + isTrash?: boolean + allowShare?: boolean + allowMove?: boolean + allowCopy?: boolean + allowQuickLink?: boolean + allowDelete?: boolean + mutations?: ReturnType + onDownloadBulk?: (files: DriveFileInfo[]) => Promise +}) { + const clearSelection = useDriveUIStore((s) => s.clearSelection) + const setSharePath = useDriveUIStore((s) => s.setSharePath) + const mutationsDefault = useDriveMutations() + const mutations = mutationsProp ?? mutationsDefault + const [folderPickerMode, setFolderPickerMode] = useState(null) + const [moreOpen, setMoreOpen] = useState(false) + const n = targets.length + + if (n === 0) return null + + const single = n === 1 ? targets[0]! : null + const canShare = Boolean( + !isTrash && allowShare && single && canShareDriveItem(single) + ) + + const run = async (fn: () => Promise, ok: string, err: string) => { + try { + await fn() + toast.success(ok) + clearSelection() + } catch { + toast.error(err) + } + } + + const onShare = () => { + if (!canShare || !single) { + toast.error("Sélectionnez un seul élément pour partager") + return + } + setSharePath(single.path, single.type) + } + + const onDownload = async () => { + const files = targets.filter((t) => t.type === "file") + if (files.length === 0) { + toast.error("Aucun fichier à télécharger") + return + } + try { + if (onDownloadBulk) { + await onDownloadBulk(files) + } else { + for (const file of files) { + await downloadDriveFile(file.path, file.name, file.name) + } + } + toast.success( + files.length > 1 ? `${files.length} fichiers téléchargés` : "Fichier téléchargé" + ) + } catch { + toast.error("Impossible de télécharger") + } + } + + const onQuickLink = async () => { + if (!single) { + toast.error("Sélectionnez un seul élément pour obtenir un lien") + return + } + try { + const share = await mutations.createShare.mutateAsync({ + path: single.path, + role: "viewer", + mode: "public", + }) + if (share.url) { + await navigator.clipboard.writeText(share.url) + toast.success("Lien copié") + } else { + toast.success("Lien de partage créé") + } + } catch { + toast.error("Impossible de créer le lien") + } + } + + const onDelete = () => + void run( + async () => { + for (const f of targets) { + await mutations.deleteFile.mutateAsync(f.path) + } + }, + n > 1 ? "Éléments supprimés" : "Élément supprimé", + "Impossible de supprimer" + ) + + const onPermanentDelete = () => + void run( + async () => { + for (const f of targets) { + await mutations.deleteTrash.mutateAsync(driveTrashItemKey(f)) + } + }, + n > 1 ? "Éléments supprimés définitivement" : "Élément supprimé définitivement", + "Impossible de supprimer définitivement" + ) + + const onRestore = () => + void run( + async () => { + for (const f of targets) { + await mutations.restore.mutateAsync(driveTrashItemKey(f)) + } + }, + n > 1 ? "Éléments restaurés" : "Élément restauré", + "Impossible de restaurer" + ) + + const noopOpen = () => {} + + return ( + <> + { + if (!open) setFolderPickerMode(null) + }} + mode={folderPickerMode ?? "move"} + sources={targets} + onMoved={clearSelection} + /> +
+
+
+ + + + + {n} sélectionné{n > 1 ? "s" : ""} + +
+ +
+ {isTrash ? ( + <> + + + + + + + + ) : ( + <> + {allowShare ? ( + + + + ) : null} + void onDownload()}> + + + {allowCopy ? ( + setFolderPickerMode("copy")} + > + + + ) : null} + {allowMove ? ( + setFolderPickerMode("move")} + > + + + ) : null} + {allowDelete ? ( + + + + ) : null} + {allowQuickLink ? ( + void onQuickLink()} + disabled={!single} + > + + + ) : null} + + + + + e.preventDefault()} + > + setMoreOpen(false)} + setSharePath={setSharePath} + mutations={mutations} + onRenameRequest={() => setMoreOpen(false)} + onMoveRequest={ + isTrash || !allowMove + ? undefined + : () => { + setMoreOpen(false) + window.setTimeout(() => setFolderPickerMode("move"), 0) + } + } + onCopyRequest={ + isTrash || !allowCopy + ? undefined + : () => { + setMoreOpen(false) + window.setTimeout(() => setFolderPickerMode("copy"), 0) + } + } + onQuickLinkRequest={ + isTrash || !allowQuickLink || !single + ? undefined + : () => { + setMoreOpen(false) + window.setTimeout(() => void onQuickLink(), 0) + } + } + /> + + + + )} +
+
+
+ + ) +} diff --git a/components/drive/drive-card-ref-context.tsx b/components/drive/drive-card-ref-context.tsx new file mode 100644 index 0000000..202edeb --- /dev/null +++ b/components/drive/drive-card-ref-context.tsx @@ -0,0 +1,36 @@ +"use client" + +import { createContext, useContext } from "react" + +type RegisterCardRef = (path: string, el: HTMLDivElement | null) => void + +const DriveCardRefContext = createContext(null) + +export function DriveCardRefProvider({ + registerCardRef, + children, +}: { + registerCardRef: RegisterCardRef + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function useDriveCardRefRegistrar(): RegisterCardRef | null { + return useContext(DriveCardRefContext) +} + +export function mergeDriveCardRefs( + path: string, + explicit: ((el: HTMLDivElement | null) => void) | undefined, + fromContext: RegisterCardRef | null +) { + return (el: HTMLDivElement | null) => { + explicit?.(el) + fromContext?.(path, el) + } +} diff --git a/components/drive/drive-file-actions-menu.tsx b/components/drive/drive-file-actions-menu.tsx new file mode 100644 index 0000000..14c2f55 --- /dev/null +++ b/components/drive/drive-file-actions-menu.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { toast } from "sonner" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { DriveFileInfo } from "@/lib/api/types" +import { displayFileName } from "@/lib/drive/display-file-name" +import { resolveRenameName } from "@/lib/drive/drive-default-name" +import { guardDriveMenuPointer, stopDriveMenuBubble } from "@/lib/drive/drive-menu-guard" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DriveNameDialog } from "@/components/drive/drive-name-dialog" +import { DRIVE_MENU_SURFACE_CLASS } from "@/components/drive/drive-file-context-menu" +import { DriveFileMenuActions } from "@/components/drive/drive-file-menu-actions" +import { MoreVertical } from "lucide-react" +import { DRIVE_MENU_BTN } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +export function useDriveActionTargets(file: DriveFileInfo, allItems: DriveFileInfo[]) { + const selectedPaths = useDriveUIStore((s) => s.selectedPaths) + return useMemo(() => { + if (selectedPaths.size > 1 && selectedPaths.has(file.path)) { + const picked = allItems.filter((item) => selectedPaths.has(item.path)) + if (picked.length > 0) return picked + } + return [file] + }, [allItems, file, selectedPaths]) +} + +export function DriveFileMenuButton({ + file, + allItems, + isTrash, + allowShare = true, + writable = true, + hideFavorite = false, + mutations: mutationsProp, + onDownloadRequest, + onOpen, + onActiveChange, + className, +}: { + file: DriveFileInfo + allItems: DriveFileInfo[] + isTrash?: boolean + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + mutations?: ReturnType + onDownloadRequest?: () => void + onOpen: () => void + /** Visual highlight only — does not update global selection or bulk bar. */ + onActiveChange?: (active: boolean) => void + className?: string +}) { + const [open, setOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [folderPickerMode, setFolderPickerMode] = useState(null) + const setSharePath = useDriveUIStore((s) => s.setSharePath) + const mutationsDefault = useDriveMutations() + const mutations = mutationsProp ?? mutationsDefault + const targets = useDriveActionTargets(file, allItems) + const renameTarget = targets.length === 1 ? targets[0] : null + + const handleRename = async (input: string) => { + const target = targets[0] + if (!target) return + const newName = resolveRenameName(target, input) + if (displayFileName(target.name) === newName) return + try { + await mutations.rename.mutateAsync({ path: target.path, new_name: newName }) + toast.success("Renommé") + } catch { + toast.error("Impossible de renommer") + throw new Error("rename failed") + } + } + + const closeDropdown = useCallback(() => { + setOpen(false) + onActiveChange?.(false) + guardDriveMenuPointer() + }, [onActiveChange]) + + const openFolderPicker = (mode: DriveFolderPickerMode) => { + closeDropdown() + window.setTimeout(() => setFolderPickerMode(mode), 0) + } + + return ( + <> + + { + if (!next) setFolderPickerMode(null) + }} + mode={folderPickerMode ?? "move"} + sources={targets} + /> + { + if (next) { + setOpen(true) + onActiveChange?.(true) + return + } + closeDropdown() + }} + > + + + + e.preventDefault()} + onPointerDown={(e) => stopDriveMenuBubble(e)} + onClick={(e) => e.stopPropagation()} + > + { + closeDropdown() + onOpen() + }} + onClose={closeDropdown} + setSharePath={setSharePath} + mutations={mutations} + onRenameRequest={() => { + closeDropdown() + window.setTimeout(() => setRenameOpen(true), 0) + }} + onMoveRequest={isTrash ? undefined : () => openFolderPicker("move")} + onCopyRequest={isTrash ? undefined : () => openFolderPicker("copy")} + onDownloadRequest={onDownloadRequest} + /> + + + + ) +} diff --git a/components/drive/drive-file-context-menu.tsx b/components/drive/drive-file-context-menu.tsx new file mode 100644 index 0000000..dc90f69 --- /dev/null +++ b/components/drive/drive-file-context-menu.tsx @@ -0,0 +1,416 @@ +"use client" + +import type { ReactNode } from "react" +import { useCallback, useMemo, useRef, useState } from "react" +import { toast } from "sonner" +import { + ContextMenu, + ContextMenuContent, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { DriveFileInfo } from "@/lib/api/types" +import { displayFileName } from "@/lib/drive/display-file-name" +import { resolveRenameName } from "@/lib/drive/drive-default-name" +import { useLongPress } from "@/hooks/use-long-press" +import { useIsMobile } from "@/hooks/use-mobile" +import { mergeDriveCardRefs, useDriveCardRefRegistrar } from "@/components/drive/drive-card-ref-context" +import { useDriveDragSource } from "@/lib/hooks/use-drive-drag-source" +import { useDriveDropTarget } from "@/lib/hooks/use-drive-drop-target" +import { + guardDriveMenuPointer, + isCardOpenSuppressed, + isFromDriveMenu, +} from "@/lib/drive/drive-menu-guard" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DriveNameDialog } from "@/components/drive/drive-name-dialog" +import { DriveFileMenuActions } from "@/components/drive/drive-file-menu-actions" +import { MAIL_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes" +import { cn } from "@/lib/utils" + +export const DRIVE_MENU_SURFACE_CLASS = cn( + MAIL_MENU_SURFACE_CLASS, + "z-50 min-w-[12rem] p-1" +) + +export const DRIVE_CARD_HIGHLIGHT_CLASS = "bg-mail-active" + +export const DRIVE_DROP_TARGET_CLASS = + "ring-2 ring-[#1a73e8] ring-inset bg-[#e8f0fe] dark:bg-primary/20" + +const MOBILE_TAP_SLOP_PX = 10 + +export function DriveFileContextMenu({ + file, + allItems, + isTrash, + allowShare = true, + writable = true, + hideFavorite = false, + disableDnd = false, + mutations: mutationsProp, + onDownloadRequest, + onOpen, + children, + className, + variant = "default", + registerRef, + onItemClick, + onContextMenuActiveChange, +}: { + file: DriveFileInfo + allItems: DriveFileInfo[] + isTrash?: boolean + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + disableDnd?: boolean + mutations?: ReturnType + onDownloadRequest?: () => void + onOpen: () => void + children: ReactNode + className?: string + variant?: "default" | "grid" + registerRef?: (el: HTMLDivElement | null) => void + onItemClick?: (file: DriveFileInfo, e: React.MouseEvent) => void + /** Visual highlight while right-click menu is open (grid cards). */ + onContextMenuActiveChange?: (active: boolean) => void +}) { + const isMobile = useIsMobile() + const isGrid = variant === "grid" + const registerCardRef = useDriveCardRefRegistrar() + const dndEnabled = !isMobile && !isTrash && !disableDnd + const isFolder = file.type === "directory" + const { dragProps } = useDriveDragSource({ file, allItems, disabled: !dndEnabled }) + const { dropProps, canDrop, isOver } = useDriveDropTarget({ + folderPath: file.path, + disabled: !dndEnabled || !isFolder, + }) + const [sheetOpen, setSheetOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [contextMenuOpen, setContextMenuOpen] = useState(false) + const [folderPickerMode, setFolderPickerMode] = useState(null) + const selectedPaths = useDriveUIStore((s) => s.selectedPaths) + const selectionMode = useDriveUIStore((s) => s.selectionMode) + const toggleSelect = useDriveUIStore((s) => s.toggleSelect) + const enterSelectionMode = useDriveUIStore((s) => s.enterSelectionMode) + const setSharePath = useDriveUIStore((s) => s.setSharePath) + const mutationsDefault = useDriveMutations() + const mutations = mutationsProp ?? mutationsDefault + + const openRenameDialog = useCallback(() => { + guardDriveMenuPointer() + window.setTimeout(() => setRenameOpen(true), 0) + }, []) + + /** Context menu / long-press sheet: always the file under cursor, never bulk selection. */ + const targets = useMemo(() => [file], [file]) + + const openSheet = useCallback(() => setSheetOpen(true), []) + + const longPress = useLongPress(openSheet, { disabled: !isMobile }) + const touchStartRef = useRef<{ x: number; y: number } | null>(null) + const touchMovedRef = useRef(false) + + const handleMobilePointerDown = useCallback( + (e: React.PointerEvent) => { + touchStartRef.current = { x: e.clientX, y: e.clientY } + touchMovedRef.current = false + longPress.onPointerDown(e) + }, + [longPress] + ) + + const handleMobilePointerMove = useCallback((e: React.PointerEvent) => { + const start = touchStartRef.current + if (!start) return + const dx = e.clientX - start.x + const dy = e.clientY - start.y + if (dx * dx + dy * dy > MOBILE_TAP_SLOP_PX * MOBILE_TAP_SLOP_PX) { + touchMovedRef.current = true + } + }, []) + + const handleMobilePointerEnd = useCallback(() => { + touchStartRef.current = null + longPress.onPointerUp() + }, [longPress]) + + const handleMobileClickCapture = useCallback( + (e: React.MouseEvent) => { + longPress.onClickCapture(e) + if (touchMovedRef.current) { + e.preventDefault() + e.stopPropagation() + touchMovedRef.current = false + } + }, + [longPress] + ) + + const handleRename = async (input: string) => { + const target = targets[0] + if (!target) return + const newName = resolveRenameName(target, input) + if (displayFileName(target.name) === newName) { + return + } + try { + await mutations.rename.mutateAsync({ path: target.path, new_name: newName }) + toast.success("Renommé") + } catch { + toast.error("Impossible de renommer") + throw new Error("rename failed") + } + } + + const shouldIgnoreCardAction = (target: EventTarget | null) => + isFromDriveMenu(target) || isCardOpenSuppressed() + + const handleMobileTap = (e: React.MouseEvent) => { + if (shouldIgnoreCardAction(e.target)) { + e.preventDefault() + e.stopPropagation() + return + } + e.preventDefault() + e.stopPropagation() + if (selectionMode) { + toggleSelect(file.path, !selectedPaths.has(file.path)) + return + } + onOpen() + } + + const touchProps = isMobile + ? { + onContextMenu: (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + openSheet() + }, + onPointerDown: handleMobilePointerDown, + onPointerMove: handleMobilePointerMove, + onPointerUp: handleMobilePointerEnd, + onPointerLeave: handleMobilePointerEnd, + onPointerCancel: handleMobilePointerEnd, + onClickCapture: handleMobileClickCapture, + } + : {} + + const handlePointerDownSelect = (e: React.PointerEvent) => { + if (e.pointerType === "mouse" && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + toggleSelect(file.path, !selectedPaths.has(file.path)) + } + if (isMobile) { + handleMobilePointerDown(e) + } + } + + const handleClickSelect = (e: React.MouseEvent) => { + if (shouldIgnoreCardAction(e.target)) { + e.preventDefault() + e.stopPropagation() + return + } + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + e.stopPropagation() + toggleSelect(file.path, !selectedPaths.has(file.path)) + } + } + + const handleGridClick = (e: React.MouseEvent) => { + if (shouldIgnoreCardAction(e.target)) { + e.preventDefault() + e.stopPropagation() + return + } + onItemClick?.(file, e) + } + + const handleOpenDoubleClick = (e: React.MouseEvent) => { + if (shouldIgnoreCardAction(e.target)) { + e.preventDefault() + e.stopPropagation() + return + } + e.preventDefault() + e.stopPropagation() + onOpen() + } + + const handleContextMenuOpenChange = useCallback( + (open: boolean) => { + if (open) { + setContextMenuOpen(true) + onContextMenuActiveChange?.(true) + return + } + setContextMenuOpen(false) + onContextMenuActiveChange?.(false) + guardDriveMenuPointer() + }, + [onContextMenuActiveChange] + ) + + const closeContextMenu = useCallback(() => { + handleContextMenuOpenChange(false) + }, [handleContextMenuOpenChange]) + + const mergedRegisterRef = mergeDriveCardRefs(file.path, registerRef, registerCardRef) + + const isSelected = selectedPaths.has(file.path) + + const trigger = ( +
+ {children} +
+ ) + + const renameTarget = targets.length === 1 ? targets[0] : null + + const openFolderPicker = (mode: DriveFolderPickerMode) => { + setSheetOpen(false) + window.setTimeout(() => setFolderPickerMode(mode), 0) + } + + const menuActionsProps = { + targets, + isTrash, + allowShare, + writable, + hideFavorite, + onOpen, + setSharePath, + mutations, + onRenameRequest: openRenameDialog, + onMoveRequest: isTrash || disableDnd ? undefined : () => openFolderPicker("move"), + onCopyRequest: isTrash || disableDnd ? undefined : () => openFolderPicker("copy"), + onDownloadRequest, + onEnterSelectionMode: isTrash + ? undefined + : () => { + enterSelectionMode(file.path) + }, + } + + const menus = ( + <> + + { + if (!open) setFolderPickerMode(null) + }} + mode={folderPickerMode ?? "move"} + sources={targets} + /> + + + + {targets.length > 1 + ? `${targets.length} éléments` + : displayFileName(file.name)} + + + {targets.length > 1 + ? `Actions pour ${targets.length} éléments sélectionnés.` + : `Actions pour ${displayFileName(file.name)}.`} + +

+ {targets.length > 1 + ? `${targets.length} éléments sélectionnés` + : displayFileName(file.name)} +

+
+ setSheetOpen(false)} + onRenameRequest={() => { + setSheetOpen(false) + openRenameDialog() + }} + /> +
+
+
+ + ) + + if (isMobile) { + return ( + <> + {trigger} + {menus} + + ) + } + + const contextMenu = ( + + {trigger} + e.preventDefault()} + > + + + + ) + + return ( + <> + {contextMenu} + {menus} + + ) +} diff --git a/components/drive/drive-file-menu-actions.tsx b/components/drive/drive-file-menu-actions.tsx new file mode 100644 index 0000000..6811f73 --- /dev/null +++ b/components/drive/drive-file-menu-actions.tsx @@ -0,0 +1,370 @@ +"use client" + +import type { ReactNode } from "react" +import { toast } from "sonner" +import { + CheckSquare, + Copy, + Download, + ExternalLink, + FolderInput, + Link2, + Pencil, + Star, + Trash2, + Undo2, +} from "lucide-react" +import { + ContextMenuItem, +} from "@/components/ui/context-menu" +import { + DropdownMenuItem, +} from "@/components/ui/dropdown-menu" +import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { DriveFileInfo } from "@/lib/api/types" +import { + guardDriveMenuPointer, + stopDriveMenuEvent, +} from "@/lib/drive/drive-menu-guard" +import { cn } from "@/lib/utils" +import { driveTrashItemKey } from "@/lib/drive/drive-trash" + +export const DRIVE_MENU_ITEM_CLASS = + "gap-3 py-2 text-[#3c4043] focus:text-[#3c4043] dark:text-[#e8eaed] dark:focus:text-[#e8eaed] [&_svg]:text-[#3c4043] dark:[&_svg]:text-[#e8eaed]" + +export const DRIVE_MENU_ITEM_DESTRUCTIVE_CLASS = + "gap-3 py-2 text-destructive focus:text-destructive [&_svg]:text-destructive" + +const SHEET_ACTION_CLASS = + "flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-[#3c4043] transition-colors hover:bg-accent active:bg-accent/80 dark:text-[#e8eaed] [&_svg]:text-[#3c4043] dark:[&_svg]:text-[#e8eaed]" + +export function canShareDriveItem(item: DriveFileInfo) { + return item.type === "file" || item.type === "directory" +} + +function DriveMenuItemIcon({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +type Mutations = ReturnType + +type DriveFileMenuActionsProps = { + variant: "dropdown" | "context" | "sheet" + targets: DriveFileInfo[] + isTrash?: boolean + allowShare?: boolean + /** When false, hide rename / move / copy / delete. */ + writable?: boolean + /** When true, hide the "Ouvrir" action (e.g. current folder in breadcrumb). */ + hideOpen?: boolean + /** When false, hide favorite actions (public share). */ + hideFavorite?: boolean + onOpen: () => void + onClose?: () => void + setSharePath: (path: string | null, itemType?: "file" | "directory" | null) => void + mutations: Mutations + onRenameRequest: () => void + onMoveRequest?: () => void + onCopyRequest?: () => void + onDownloadRequest?: () => void + onQuickLinkRequest?: () => void + onEnterSelectionMode?: () => void +} + +export function DriveFileMenuActions({ + variant, + targets, + isTrash, + allowShare = true, + writable = true, + hideOpen = false, + hideFavorite = false, + onOpen, + onClose, + setSharePath, + mutations, + onRenameRequest, + onMoveRequest, + onCopyRequest, + onDownloadRequest, + onQuickLinkRequest, + onEnterSelectionMode, +}: DriveFileMenuActionsProps) { + const single = targets.length === 1 ? targets[0]! : null + const multi = targets.length > 1 + + const runAsync = async (fn: () => Promise, ok: string, err: string) => { + onClose?.() + try { + await fn() + toast.success(ok) + } catch { + toast.error(err) + } + } + + const runMenuAction = ( + fn: () => void, + asyncFn?: () => Promise, + ok?: string, + err?: string + ) => { + guardDriveMenuPointer() + onClose?.() + if (asyncFn && ok && err) { + void runAsync(asyncFn, ok, err) + return + } + fn() + } + + const selectAction = (fn: () => void) => () => { + guardDriveMenuPointer() + onClose?.() + window.setTimeout(fn, 0) + } + + const favoriteLabel = targets.every((f) => f.is_favorite) + ? "Retirer des favoris" + : "Ajouter aux favoris" + + const favoriteAsync = async () => { + for (const f of targets) { + await mutations.favorite.mutateAsync({ + path: f.path, + favorite: !f.is_favorite, + }) + } + } + + const deleteAsync = async () => { + for (const f of targets) { + await mutations.deleteFile.mutateAsync(f.path) + } + } + + const restoreAsync = async () => { + for (const f of targets) { + await mutations.restore.mutateAsync(driveTrashItemKey(f)) + } + } + + const deleteTrashAsync = async () => { + for (const f of targets) { + await mutations.deleteTrash.mutateAsync(driveTrashItemKey(f)) + } + } + + const actions: Array<{ + key: string + label: string + icon: ReactNode + destructive?: boolean + visible: boolean + onSelect: () => void + }> = [ + { + key: "open", + label: "Ouvrir", + icon: , + visible: !hideOpen && !multi && Boolean(single), + onSelect: () => runMenuAction(() => window.setTimeout(() => onOpen(), 0)), + }, + { + key: "select", + label: "Sélectionner", + icon: , + visible: variant === "sheet" && Boolean(!isTrash && single && onEnterSelectionMode), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onEnterSelectionMode?.(), 0) + }), + }, + { + key: "share", + label: "Partager", + icon: , + visible: Boolean(!isTrash && allowShare && !multi && single && canShareDriveItem(single)), + onSelect: () => runMenuAction(() => setSharePath(single!.path, single!.type)), + }, + { + key: "copy", + label: multi ? `Copier vers (${targets.length})` : "Copier vers", + icon: , + visible: Boolean(writable && !isTrash && onCopyRequest), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onCopyRequest?.(), 0) + }), + }, + { + key: "move", + label: multi ? `Déplacer vers (${targets.length})` : "Déplacer vers", + icon: , + visible: Boolean(writable && !isTrash && onMoveRequest), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onMoveRequest?.(), 0) + }), + }, + { + key: "download", + label: "Télécharger", + icon: , + visible: Boolean(!isTrash && onDownloadRequest), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onDownloadRequest?.(), 0) + }), + }, + { + key: "quick-link", + label: "Obtenir le lien", + icon: , + visible: Boolean(!isTrash && onQuickLinkRequest && single), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onQuickLinkRequest?.(), 0) + }), + }, + { + key: "favorite", + label: favoriteLabel, + icon: , + visible: !isTrash && !hideFavorite, + onSelect: () => + runMenuAction( + () => {}, + favoriteAsync, + favoriteLabel === "Retirer des favoris" ? "Retiré des favoris" : "Ajouté aux favoris", + "Impossible de modifier les favoris" + ), + }, + { + key: "restore", + label: `Restaurer${multi ? ` (${targets.length})` : ""}`, + icon: , + visible: Boolean(isTrash), + onSelect: () => + runMenuAction( + () => {}, + restoreAsync, + "Élément(s) restauré(s)", + "Impossible de restaurer" + ), + }, + { + key: "delete-trash", + label: `Supprimer définitivement${multi ? ` (${targets.length})` : ""}`, + icon: , + destructive: true, + visible: Boolean(isTrash && writable), + onSelect: () => + runMenuAction( + () => {}, + deleteTrashAsync, + "Élément(s) supprimé(s) définitivement", + "Impossible de supprimer définitivement" + ), + }, + { + key: "rename", + label: "Renommer", + icon: , + visible: Boolean(writable && !isTrash && single && !multi), + onSelect: () => + runMenuAction(() => { + window.setTimeout(() => onRenameRequest(), 0) + }), + }, + { + key: "delete", + label: `Supprimer${multi ? ` (${targets.length})` : ""}`, + icon: , + destructive: true, + visible: writable && !isTrash, + onSelect: () => + runMenuAction(() => {}, deleteAsync, "Supprimé", "Impossible de supprimer"), + }, + ] + + if (variant === "sheet") { + return ( + <> + {actions + .filter((action) => action.visible) + .map((action) => ( + + ))} + + ) + } + + if (variant === "context") { + return ( + <> + {actions + .filter((action) => action.visible) + .map((action) => ( + stopDriveMenuEvent(e)} + onSelect={selectAction(action.onSelect)} + > + {action.icon} + {action.label} + + ))} + + ) + } + + return ( + <> + {actions + .filter((action) => action.visible) + .map((action) => ( + stopDriveMenuEvent(e)} + onSelect={() => action.onSelect()} + > + {action.icon} + {action.label} + + ))} + + ) +} diff --git a/components/drive/drive-filter-bar.tsx b/components/drive/drive-filter-bar.tsx new file mode 100644 index 0000000..d1e21f7 --- /dev/null +++ b/components/drive/drive-filter-bar.tsx @@ -0,0 +1,382 @@ +"use client" + +import { useEffect, useState } from "react" +import { Calendar, ChevronDown, Search } from "lucide-react" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries" +import type { DriveMimeCategory, DriveSourceId } from "@/lib/drive/drive-filters" +import { + useDriveFiltersStore, + type DriveDatePreset, +} from "@/lib/stores/drive-filters-store" +import { suitePublicAsset } from "@/lib/suite/suite-public-asset" +import { DriveMimeCategoryIcon } from "@/lib/drive/drive-file-icon" +import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +const FILTER_BORDER = "border-mail-border-subtle" +const FILTER_DIVIDER = "border-mail-border-subtle" +const FILTER_DROPDOWN_CONTENT_CLASS = cn( + FILTER_BORDER, + "bg-mail-surface-elevated shadow-sm" +) +const FILTER_CHECKBOX_ITEM_CLASS = + "gap-3 py-2 pl-2 pr-8 [&>span:first-child]:left-auto [&>span:first-child]:right-2" + +const TYPE_OPTIONS: { id: DriveMimeCategory; label: string }[] = [ + { id: "folder", label: "Dossiers" }, + { id: "document", label: "Documents" }, + { id: "spreadsheet", label: "Feuilles de calcul" }, + { id: "presentation", label: "Présentations" }, + { id: "image", label: "Photos et images" }, + { id: "pdf", label: "Fichiers PDF" }, + { id: "video", label: "Vidéos" }, + { id: "audio", label: "Audio" }, + { id: "archive", label: "Archives (zip)" }, + { id: "other", label: "Autres fichiers" }, +] + +const SOURCE_OPTIONS: { id: DriveSourceId; label: string; iconSrc: string }[] = [ + { + id: "ultimail", + label: "Ultimail", + iconSrc: suitePublicAsset("/brand/ultimail-header-icon.png"), + }, + { + id: "ultimeet", + label: "Ultimeet", + iconSrc: suitePublicAsset("/ultimeet-mark.svg"), + }, +] + +function SourceAppIcon({ src, alt }: { src: string; alt: string }) { + return ( + {alt} + ) +} + +const DATE_OPTIONS: { id: Exclude; label: string }[] = [ + { id: "today", label: "Aujourd'hui" }, + { id: "last7", label: "7 derniers jours" }, + { id: "last30", label: "30 derniers jours" }, + { id: "thisYear", label: `Cette année (${new Date().getFullYear()})` }, + { id: "lastYear", label: `Année dernière (${new Date().getFullYear() - 1})` }, +] + +function FilterChip({ + label, + active, + children, +}: { + label: string + active?: boolean + children: React.ReactNode +}) { + return ( + + + + + {children} + + ) +} + +function TypeFilterDropdown() { + const types = useDriveFiltersStore((s) => s.types) + const toggleType = useDriveFiltersStore((s) => s.toggleType) + + return ( + 0}> + + {TYPE_OPTIONS.map((opt) => ( + toggleType(opt.id)} + className={FILTER_CHECKBOX_ITEM_CLASS} + > + + {opt.label} + + ))} + + + ) +} + +function ContactsFilterDropdown() { + const contactName = useDriveFiltersStore((s) => s.contactName) + const setContact = useDriveFiltersStore((s) => s.setContact) + const [query, setQuery] = useState("") + const { data: results = [] } = useSearchContacts(query) + const chipLabel = contactName ?? "Contacts" + + return ( + + +
+
+ + setQuery(e.target.value)} + placeholder="Rechercher des contacts…" + className={cn("h-9 pl-8", FILTER_BORDER)} + /> +
+
+
+ {query.length < 2 ? ( +

+ Saisissez au moins 2 caractères +

+ ) : results.length === 0 ? ( +

Aucun contact

+ ) : ( + results.map((c) => ( + { + setContact({ name: c.full_name, email: c.email }) + setQuery("") + }} + > + {c.full_name} + {c.email ? ( + {c.email} + ) : null} + + )) + )} +
+ {contactName ? ( +
+ +
+ ) : null} +
+
+ ) +} + +function DateFilterDropdown() { + const datePreset = useDriveFiltersStore((s) => s.datePreset) + const setDateRange = useDriveFiltersStore((s) => s.setDateRange) + const [open, setOpen] = useState(false) + const [customMode, setCustomMode] = useState(false) + const [draftFrom, setDraftFrom] = useState("") + const [draftTo, setDraftTo] = useState("") + + useEffect(() => { + if (open) { + setCustomMode(datePreset === "custom") + setDraftFrom(useDriveFiltersStore.getState().dateFrom ?? "") + setDraftTo(useDriveFiltersStore.getState().dateTo ?? "") + } + }, [open, datePreset]) + + const dateLabel = + datePreset === "today" + ? "Aujourd'hui" + : datePreset === "last7" + ? "7 derniers jours" + : datePreset === "last30" + ? "30 derniers jours" + : datePreset === "thisYear" + ? "Cette année" + : datePreset === "lastYear" + ? "Année dernière" + : datePreset === "custom" + ? "Période personnalisée" + : "Date de modification" + + return ( + + + + + +
+ {DATE_OPTIONS.map((opt) => ( + { + setDateRange(opt.id) + setOpen(false) + }} + className={cn(datePreset === opt.id && !customMode && "bg-accent")} + > + {opt.label} + + ))} + { + e.preventDefault() + setCustomMode(true) + }} + className={cn((datePreset === "custom" || customMode) && "bg-accent")} + > + Période personnalisée + +
+ {customMode ? ( + <> +
+ + +
+
+ + +
+ + ) : null} +
+
+ ) +} + +function SourceFilterDropdown() { + const sources = useDriveFiltersStore((s) => s.sources) + const toggleSource = useDriveFiltersStore((s) => s.toggleSource) + + return ( + 0}> + + {SOURCE_OPTIONS.map((opt) => ( + toggleSource(opt.id)} + className={FILTER_CHECKBOX_ITEM_CLASS} + > + + {opt.label} + + ))} + + + ) +} + +export function DriveFilterBar({ showContacts = true }: { showContacts?: boolean }) { + const clearAll = useDriveFiltersStore((s) => s.clearAll) + const hasFilters = useDriveFiltersStore((s) => + s.types.size > 0 || + s.sources.size > 0 || + Boolean(s.contactName) || + Boolean(s.datePreset) + ) + + return ( +
+ + {showContacts ? : null} + + + {hasFilters ? ( + + ) : null} +
+ ) +} diff --git a/components/drive/drive-folder-grid-card.tsx b/components/drive/drive-folder-grid-card.tsx new file mode 100644 index 0000000..102ce15 --- /dev/null +++ b/components/drive/drive-folder-grid-card.tsx @@ -0,0 +1,102 @@ +"use client" + +import { useCallback, useState } from "react" +import { DriveFileContextMenu } from "@/components/drive/drive-file-context-menu" +import { DriveFileMenuButton } from "@/components/drive/drive-file-actions-menu" +import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb" +import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon" +import type { DriveFileInfo } from "@/lib/api/types" +import { displayFileName } from "@/lib/drive/display-file-name" +import { cn } from "@/lib/utils" + +const FOLDER_TITLE_CLASS = + "min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043] dark:text-[#e8eaed]" + +export function DriveFolderGridCard({ + folder, + allItems, + inSharedView, + isSelected, + isTrash, + allowShare = true, + writable = true, + hideFavorite = false, + disableDnd = false, + mutations, + onDownloadItem, + publicShare, + onOpen, + onItemClick, + registerRef, +}: { + folder: DriveFileInfo + allItems: DriveFileInfo[] + inSharedView?: boolean + isSelected: boolean + isTrash?: boolean + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + disableDnd?: boolean + mutations?: ReturnType + onDownloadItem?: (file: DriveFileInfo) => void + publicShare?: PublicShareThumbContext + onOpen: () => void + onItemClick: (file: DriveFileInfo, e: React.MouseEvent) => void + registerRef?: (path: string, el: HTMLDivElement | null) => void +}) { + const [menuActive, setMenuActive] = useState(false) + const [contextMenuActive, setContextMenuActive] = useState(false) + const label = displayFileName(folder.name) + const highlighted = isSelected || menuActive || contextMenuActive + + const setRef = useCallback( + (el: HTMLDivElement | null) => registerRef?.(folder.path, el), + [folder.path, registerRef] + ) + + return ( + onDownloadItem(folder) : undefined} + onOpen={onOpen} + variant="grid" + registerRef={registerRef ? setRef : undefined} + onItemClick={onItemClick} + onContextMenuActiveChange={setContextMenuActive} + className={cn( + "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-3 transition-colors", + highlighted + ? "bg-[#c2e7ff] dark:bg-primary/25" + : "bg-[#f8f9fa] hover:bg-[#f1f3f4] dark:bg-muted/30 dark:hover:bg-muted/45" + )} + > +
+ + + {label} + +
+ onDownloadItem(folder) : undefined} + onOpen={onOpen} + onActiveChange={setMenuActive} + /> +
+ ) +} diff --git a/components/drive/drive-grid-card.tsx b/components/drive/drive-grid-card.tsx new file mode 100644 index 0000000..881bec9 --- /dev/null +++ b/components/drive/drive-grid-card.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useState } from "react" +import { FileThumbnail } from "@/components/drive/file-thumbnail" +import { DriveFileContextMenu } from "@/components/drive/drive-file-context-menu" +import { DriveFileMenuButton } from "@/components/drive/drive-file-actions-menu" +import type { DriveFileInfo } from "@/lib/api/types" +import { displayFileName } from "@/lib/drive/display-file-name" +import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb" +import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon" +import { cn } from "@/lib/utils" + +const GRID_TITLE_CLASS = + "min-w-0 flex-1 truncate text-left text-xs font-normal text-[#3c4043] dark:text-[#e8eaed]" + +export function DriveGridCard({ + file, + allItems, + inSharedView, + isSelected, + isTrash, + allowShare = true, + writable = true, + hideFavorite = false, + disableDnd = false, + mutations, + onDownloadItem, + publicShare, + onOpen, + onItemClick, + registerRef, +}: { + file: DriveFileInfo + allItems: DriveFileInfo[] + inSharedView?: boolean + isSelected: boolean + isTrash?: boolean + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + disableDnd?: boolean + mutations?: ReturnType + onDownloadItem?: (file: DriveFileInfo) => void + publicShare?: PublicShareThumbContext + onOpen: () => void + onItemClick: (file: DriveFileInfo, e: React.MouseEvent) => void + registerRef?: (path: string, el: HTMLDivElement | null) => void +}) { + const [menuActive, setMenuActive] = useState(false) + const [contextMenuActive, setContextMenuActive] = useState(false) + const label = displayFileName(file.name) + const highlighted = isSelected || menuActive || contextMenuActive + + return ( + onDownloadItem(file) : undefined} + onOpen={onOpen} + variant="grid" + registerRef={registerRef ? (el) => registerRef(file.path, el) : undefined} + onItemClick={onItemClick} + onContextMenuActiveChange={setContextMenuActive} + className={cn( + "flex cursor-pointer flex-col overflow-hidden rounded-xl transition-colors", + highlighted + ? "bg-mail-active" + : "bg-mail-surface-muted hover:bg-mail-nav-hover" + )} + > +
+ + + {label} + + onDownloadItem(file) : undefined} + onOpen={onOpen} + onActiveChange={setMenuActive} + /> +
+
+ +
+
+ ) +} diff --git a/components/drive/drive-grid-view.tsx b/components/drive/drive-grid-view.tsx new file mode 100644 index 0000000..2e8af9f --- /dev/null +++ b/components/drive/drive-grid-view.tsx @@ -0,0 +1,127 @@ +"use client" + +import { useMemo } from "react" +import { DriveGridCard } from "@/components/drive/drive-grid-card" +import { DriveFolderGridCard } from "@/components/drive/drive-folder-grid-card" +import type { DriveFileInfo } from "@/lib/api/types" +import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb" +import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +const DRIVE_GRID_COLS = + "grid w-full grid-cols-2 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6" + +const DRIVE_GRID_SECTION_GAP = "mb-6" + +export function DriveGridView({ + items, + isTrash, + inSharedView, + allowShare = true, + writable = true, + hideFavorite = false, + disableDnd = false, + mutations, + onDownloadItem, + gridClassName, + publicShare, + onOpen, + onItemClick, +}: { + items: DriveFileInfo[] + isTrash?: boolean + inSharedView?: boolean + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + disableDnd?: boolean + mutations?: ReturnType + onDownloadItem?: (file: DriveFileInfo) => void + gridClassName?: string + publicShare?: PublicShareThumbContext + onOpen: (file: DriveFileInfo) => void + onItemClick: (file: DriveFileInfo, e: React.MouseEvent) => void +}) { + const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement) + const folders = useMemo(() => items.filter((f) => f.type === "directory"), [items]) + const files = useMemo(() => items.filter((f) => f.type !== "directory"), [items]) + const gridItems = useMemo(() => items, [items]) + const mixedLayout = folderPlacement === "mixed" + + const selectedPaths = useDriveUIStore((s) => s.selectedPaths) + const sharedCardProps = { + writable, + hideFavorite, + disableDnd, + mutations, + onDownloadItem, + publicShare, + } + + return ( +
+ {mixedLayout ? ( +
+ {items.map((item) => ( + onOpen(item)} + onItemClick={onItemClick} + /> + ))} +
+ ) : ( + <> + {folders.length > 0 ? ( +
+ {folders.map((folder) => ( + onOpen(folder)} + onItemClick={onItemClick} + /> + ))} +
+ ) : null} + {files.length > 0 ? ( +
+ {files.map((file) => ( + onOpen(file)} + onItemClick={onItemClick} + /> + ))} +
+ ) : null} + + )} +
+ ) +} diff --git a/components/drive/drive-header.tsx b/components/drive/drive-header.tsx new file mode 100644 index 0000000..f75bcec --- /dev/null +++ b/components/drive/drive-header.tsx @@ -0,0 +1,74 @@ +"use client" + +import { DriveSearchBar } from "@/components/drive/drive-search-bar" +import { HeaderAccountActions } from "@/components/suite/header-account-actions" +import { Button } from "@/components/ui/button" +import { DRIVE_MAIN_INSET_X } from "@/lib/drive/drive-chrome-classes" +import type { DriveSearchScope } from "@/lib/drive/drive-search" +import type { DriveView } from "@/lib/drive/drive-url" +import { useIsMobile } from "@/hooks/use-mobile" +import { useIsXs } from "@/hooks/use-xs" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { Menu } from "lucide-react" +import { cn } from "@/lib/utils" + +export function DriveHeader({ + search, + onSearchChange, + searchScope, + onSearchScopeChange, + folderPath, + contextView, + resultsMode = false, +}: { + search: string + onSearchChange: (q: string) => void + searchScope: DriveSearchScope + onSearchScopeChange: (scope: DriveSearchScope) => void + folderPath: string + contextView: DriveView + resultsMode?: boolean +}) { + const isMobile = useIsMobile() + const isXs = useIsXs() + const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed) + const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed) + + return ( +
+ {isMobile && !isXs && sidebarCollapsed ? ( + + ) : null} +
+ +
+ +
+ ) +} diff --git a/components/drive/drive-list-modified.tsx b/components/drive/drive-list-modified.tsx new file mode 100644 index 0000000..3aed6e4 --- /dev/null +++ b/components/drive/drive-list-modified.tsx @@ -0,0 +1,31 @@ +"use client" + +import { formatDriveListDate } from "@/lib/drive/drive-date" +import { cn } from "@/lib/utils" + +export function DriveListModified({ + iso, + className, +}: { + iso: string + className?: string +}) { + const { short, full, dateTime } = formatDriveListDate(iso) + + if (!full) { + return ( + + ) + } + + return ( + + ) +} diff --git a/components/drive/drive-marquee-surface.tsx b/components/drive/drive-marquee-surface.tsx new file mode 100644 index 0000000..21080c0 --- /dev/null +++ b/components/drive/drive-marquee-surface.tsx @@ -0,0 +1,41 @@ +"use client" + +import { DriveCardRefProvider } from "@/components/drive/drive-card-ref-context" +import { useDriveMarqueeSelection } from "@/lib/hooks/use-drive-marquee-selection" +import { cn } from "@/lib/utils" + +export function DriveMarqueeSurface({ + enabled = true, + className, + children, +}: { + enabled?: boolean + className?: string + children: React.ReactNode +}) { + const { registerCardRef, onSurfacePointerDown, marquee } = + useDriveMarqueeSelection(enabled) + + return ( + +
+ {children} + {marquee ? ( +
+ ) : null} +
+ + ) +} diff --git a/components/drive/drive-mobile-bottom-bar.tsx b/components/drive/drive-mobile-bottom-bar.tsx new file mode 100644 index 0000000..062beca --- /dev/null +++ b/components/drive/drive-mobile-bottom-bar.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Menu, Plus, Search, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { DriveMobileSearchSheet } from "@/components/drive/drive-mobile-search-sheet" +import { DriveNewSheet } from "@/components/drive/drive-new-sheet" +import type { DriveFileInfo } from "@/lib/api/types" +import { openDriveItem } from "@/lib/drive/drive-open-item" +import { + type DriveSearchScope, + buildDriveSearchUrl, + defaultDriveSearchScope, + fileBrowserViewForSearchScope, +} from "@/lib/drive/drive-search" +import type { DriveView } from "@/lib/drive/drive-url" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" + +const ROUNDED_BAR_BTN = + "size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white" + +export function DriveMobileBottomBar({ + search, + onSearchChange, + searchScope, + onSearchScopeChange, + folderPath, + contextView, + resultsMode = false, + parentPath, +}: { + search: string + onSearchChange: (q: string) => void + searchScope: DriveSearchScope + onSearchScopeChange: (scope: DriveSearchScope) => void + folderPath: string + contextView: DriveView + resultsMode?: boolean + parentPath: string +}) { + const router = useRouter() + const openPreview = useDriveUIStore((s) => s.openPreview) + const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed) + const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed) + const sidebarOpen = !sidebarCollapsed + const [searchOpen, setSearchOpen] = useState(false) + const [newOpen, setNewOpen] = useState(false) + + const toggleSidebar = () => setSidebarCollapsed(!sidebarCollapsed) + const effectiveScope = + searchScope === "folder" && folderPath === "/" + ? defaultDriveSearchScope(contextView, folderPath) + : searchScope + + const submitSearch = () => { + const q = search.trim() + if (!q) return + router.push( + buildDriveSearchUrl({ + query: q, + scope: effectiveScope, + folderPath: effectiveScope === "folder" ? folderPath : "/", + }) + ) + } + + const openSuggestion = (item: DriveFileInfo, contextItems: DriveFileInfo[]) => { + openDriveItem(item, { + router, + openPreview, + view: fileBrowserViewForSearchScope(effectiveScope), + contextItems, + }) + } + + return ( + <> + setSearchOpen(false)} + search={search} + onSearchChange={onSearchChange} + searchScope={searchScope} + onSearchScopeChange={onSearchScopeChange} + folderPath={folderPath} + contextView={contextView} + resultsMode={resultsMode} + onPickItem={openSuggestion} + onSubmitSearch={submitSearch} + /> + + +
+
+ +
+ + + {!sidebarOpen && ( + <> + + + + + )} +
+
+ + ) +} diff --git a/components/drive/drive-mobile-search-sheet.tsx b/components/drive/drive-mobile-search-sheet.tsx new file mode 100644 index 0000000..16dfa4b --- /dev/null +++ b/components/drive/drive-mobile-search-sheet.tsx @@ -0,0 +1,129 @@ +"use client" + +import { ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" +import { DriveSearchBar } from "@/components/drive/drive-search-bar" +import { DriveSearchSuggestionsPanel } from "@/components/drive/drive-search-suggestions" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import type { DriveFileInfo } from "@/lib/api/types" +import { useDriveSearchSuggestions } from "@/lib/api/hooks/use-drive-queries" +import { + type DriveSearchScope, + defaultDriveSearchScope, +} from "@/lib/drive/drive-search" +import type { DriveView } from "@/lib/drive/drive-url" +import { useDebouncedValue } from "@/lib/hooks/use-debounced-value" +import { + DRIVE_DIALOG_DIVIDER, + DRIVE_SHEET_CONTENT, + DRIVE_SHEET_OVERLAY, + DRIVE_TEXT_SECONDARY, +} from "@/lib/drive/drive-dialog-styles" +import { cn } from "@/lib/utils" + +export function DriveMobileSearchSheet({ + open, + onClose, + search, + onSearchChange, + searchScope, + onSearchScopeChange, + folderPath, + contextView, + resultsMode = false, + onPickItem, + onSubmitSearch, +}: { + open: boolean + onClose: () => void + search: string + onSearchChange: (q: string) => void + searchScope: DriveSearchScope + onSearchScopeChange: (scope: DriveSearchScope) => void + folderPath: string + contextView: DriveView + resultsMode?: boolean + onPickItem: (item: DriveFileInfo, contextItems: DriveFileInfo[]) => void + onSubmitSearch: () => void +}) { + const showFolderScope = folderPath !== "/" + const effectiveScope = + searchScope === "folder" && !showFolderScope + ? defaultDriveSearchScope(contextView, folderPath) + : searchScope + const searchPath = effectiveScope === "folder" ? folderPath : "/" + const debouncedQuery = useDebouncedValue(search, 250) + const { data, isFetching } = useDriveSearchSuggestions( + debouncedQuery, + effectiveScope, + searchPath, + open && !resultsMode && debouncedQuery.trim().length >= 2 + ) + const suggestions = data?.files ?? [] + + return ( + { if (!isOpen) onClose() }}> + + Rechercher dans Drive + + Rechercher des fichiers et dossiers dans Drive. + +
+ +
+ +
+
+
+ {search.trim().length >= 2 ? ( + { + onPickItem(item, suggestions) + onClose() + }} + onSubmitSearch={() => { + onSubmitSearch() + onClose() + }} + className="static rounded-xl shadow-none" + /> + ) : ( +

+ Recherchez des fichiers et dossiers dans tout le Drive. +

+ )} +
+
+
+ ) +} diff --git a/components/drive/drive-move-dialog.tsx b/components/drive/drive-move-dialog.tsx new file mode 100644 index 0000000..3099a29 --- /dev/null +++ b/components/drive/drive-move-dialog.tsx @@ -0,0 +1,207 @@ +"use client" + +import { useMemo, useState } from "react" +import { ChevronRight, Folder } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { useDriveList, useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { DriveFileInfo } from "@/lib/api/types" +import { displayFileName } from "@/lib/drive/display-file-name" +import { + DRIVE_BTN_GHOST, + DRIVE_BTN_PRIMARY, + DRIVE_DIALOG_CONTENT, + DRIVE_DIALOG_DIVIDER, + DRIVE_DIALOG_FOOTER, + DRIVE_DIALOG_OVERLAY, + DRIVE_TEXT_PRIMARY, + DRIVE_TEXT_SECONDARY, + DRIVE_TEXT_TITLE, +} from "@/lib/drive/drive-dialog-styles" +import { + copyDriveItemsToFolder, + isMoveDestinationBlocked, + moveDriveItemsToFolder, +} from "@/lib/drive/drive-move-items" +import { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree" +import { cn } from "@/lib/utils" + +export type DriveFolderPickerMode = "move" | "copy" + +export function DriveMoveDialog({ + open, + onOpenChange, + sources, + onMoved, + mode = "move", +}: { + open: boolean + onOpenChange: (open: boolean) => void + sources: DriveFileInfo[] + onMoved?: () => void + mode?: DriveFolderPickerMode +}) { + const [browsePath, setBrowsePath] = useState("/") + const mutations = useDriveMutations() + const list = useDriveList(browsePath, 1, "", open) + const sourcePaths = useMemo(() => new Set(sources.map((s) => s.path)), [sources]) + const isCopy = mode === "copy" + + const folders = useMemo( + () => + (list.data?.files ?? []).filter( + (f) => f.type === "directory" && !sourcePaths.has(f.path) + ), + [list.data?.files, sourcePaths] + ) + + const crumbs = useMemo(() => { + const normalized = normalizeDriveFolderPath(browsePath) + if (normalized === "/") return [{ path: "/", label: "Mon Drive" }] + const parts = normalized.slice(1).split("/") + const out: { path: string; label: string }[] = [{ path: "/", label: "Mon Drive" }] + for (let i = 0; i < parts.length; i++) { + const path = "/" + parts.slice(0, i + 1).join("/") + out.push({ path, label: displayFileName(parts[i]!) }) + } + return out + }, [browsePath]) + + const blockedDestination = isMoveDestinationBlocked(sources, browsePath) + + const confirm = async () => { + if (blockedDestination) { + toast.error( + isCopy + ? "Impossible de copier un dossier dans lui-même" + : "Impossible de déplacer un dossier dans lui-même" + ) + return + } + try { + if (isCopy) { + await copyDriveItemsToFolder(sources, browsePath, (body) => + mutations.copy.mutateAsync(body) + ) + toast.success(sources.length > 1 ? "Éléments copiés" : "Élément copié") + } else { + await moveDriveItemsToFolder(sources, browsePath, (body) => + mutations.move.mutateAsync(body) + ) + toast.success(sources.length > 1 ? "Éléments déplacés" : "Élément déplacé") + } + onOpenChange(false) + onMoved?.() + } catch { + toast.error(isCopy ? "Impossible de copier" : "Impossible de déplacer") + } + } + + const pending = isCopy ? mutations.copy.isPending : mutations.move.isPending + const titleVerb = isCopy ? "Copier" : "Déplacer" + const countLabel = sources.length > 1 ? `${sources.length} éléments` : "l'élément" + + return ( + { + if (next) setBrowsePath("/") + onOpenChange(next) + }} + > + + + + {titleVerb} {countLabel} + + + {isCopy + ? `Choisir le dossier de destination pour copier ${countLabel}.` + : `Choisir le dossier de destination pour déplacer ${countLabel}.`} + + +
+
+ {crumbs.map((crumb, i) => ( + + {i > 0 ? ( + + ) : null} + + + ))} +
+
+ {list.isLoading ? ( +

Chargement…

+ ) : folders.length === 0 ? ( +

Aucun sous-dossier

+ ) : ( + folders.map((folder) => ( + + )) + )} +
+
+ + + + +
+
+ ) +} diff --git a/components/drive/drive-name-dialog.tsx b/components/drive/drive-name-dialog.tsx new file mode 100644 index 0000000..8b6faf3 --- /dev/null +++ b/components/drive/drive-name-dialog.tsx @@ -0,0 +1,127 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + DRIVE_BTN_GHOST, + DRIVE_BTN_PRIMARY, + DRIVE_DIALOG_BODY, + DRIVE_DIALOG_CONTENT, + DRIVE_DIALOG_FOOTER, + DRIVE_DIALOG_HEADER, + DRIVE_DIALOG_OVERLAY, + DRIVE_FIELD_CLASS, + DRIVE_TEXT_SECONDARY, + DRIVE_TEXT_TITLE, +} from "@/lib/drive/drive-dialog-styles" +import { cn } from "@/lib/utils" + +export function DriveNameDialog({ + open, + onOpenChange, + title, + description, + defaultValue, + confirmLabel = "Créer", + onConfirm, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + defaultValue: string + confirmLabel?: string + onConfirm: (name: string) => void | Promise +}) { + const [value, setValue] = useState(defaultValue) + const [busy, setBusy] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + if (!open) return + setValue(defaultValue) + const t = setTimeout(() => { + const el = inputRef.current + if (!el) return + el.focus() + el.select() + }, 0) + return () => clearTimeout(t) + }, [open, defaultValue]) + + const submit = async () => { + const trimmed = value.trim() + if (!trimmed || busy) return + setBusy(true) + try { + await onConfirm(trimmed) + onOpenChange(false) + } finally { + setBusy(false) + } + } + + return ( + + e.preventDefault()} + {...(description ? {} : { "aria-describedby": undefined })} + > + + + {title} + + {description ? ( + + {description} + + ) : null} + +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit() + }} + disabled={busy} + autoComplete="off" + className={DRIVE_FIELD_CLASS} + /> +
+ + + + +
+
+ ) +} diff --git a/components/drive/drive-new-sheet.tsx b/components/drive/drive-new-sheet.tsx new file mode 100644 index 0000000..0945cf2 --- /dev/null +++ b/components/drive/drive-new-sheet.tsx @@ -0,0 +1,171 @@ +"use client" + +import type { InputHTMLAttributes, ReactNode } from "react" +import { + FileSpreadsheet, + FileText, + FolderPlus, + FolderUp, + Presentation, + Upload, +} from "lucide-react" +import { DriveNameDialog } from "@/components/drive/drive-name-dialog" +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet" +import { + DRIVE_NEW_MENU_ITEM_CLASS, + useDriveNewMenu, +} from "@/lib/hooks/use-drive-new-menu" +import { + DRIVE_SHEET_CONTENT, + DRIVE_SHEET_OVERLAY, + DRIVE_TEXT_TITLE, + DRIVE_DIALOG_DIVIDER, +} from "@/lib/drive/drive-dialog-styles" +import { cn } from "@/lib/utils" + +function SheetAction({ + icon, + label, + onClick, + className, +}: { + icon: ReactNode + label: string + onClick?: () => void + className?: string +}) { + return ( + + ) +} + +export function DriveNewSheet({ + parentPath, + open, + onOpenChange, +}: { + parentPath: string + open: boolean + onOpenChange: (open: boolean) => void +}) { + const { + pendingKind, + pendingMeta, + defaultName, + confirmNew, + uploadFiles, + importFolder, + pickKind, + closeNameDialog, + } = useDriveNewMenu(parentPath) + + const closeSheet = () => onOpenChange(false) + + const pick = (kind: Parameters[0]) => { + pickKind(kind) + closeSheet() + } + + return ( + <> + { + if (!next) closeNameDialog() + }} + title={ + pendingKind === "folder" + ? "Nouveau dossier" + : pendingMeta + ? `Nouveau ${pendingMeta.menuLabel.toLowerCase()}` + : "Nouveau" + } + defaultValue={defaultName} + confirmLabel="Créer" + onConfirm={confirmNew} + /> + + + Nouveau + + Créer un document, un tableur, une présentation ou un dossier. + +
+

Nouveau

+
+
+ } + label="Document" + onClick={() => pick("document")} + /> + } + label="Tableur" + onClick={() => pick("spreadsheet")} + /> + } + label="Présentation" + onClick={() => pick("presentation")} + /> + } + label="Dossier" + onClick={() => pick("folder")} + /> + + +
+
+
+ + ) +} diff --git a/components/drive/drive-scroll-end-spacer.tsx b/components/drive/drive-scroll-end-spacer.tsx new file mode 100644 index 0000000..26a2f9e --- /dev/null +++ b/components/drive/drive-scroll-end-spacer.tsx @@ -0,0 +1,6 @@ +import { DRIVE_SCROLL_END_SPACER_CLASS } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +export function DriveScrollEndSpacer({ className }: { className?: string }) { + return
+} diff --git a/components/drive/drive-search-bar.tsx b/components/drive/drive-search-bar.tsx new file mode 100644 index 0000000..b63040a --- /dev/null +++ b/components/drive/drive-search-bar.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { Search, X } from "lucide-react" +import { DriveSearchSuggestionsPanel } from "@/components/drive/drive-search-suggestions" +import { Button } from "@/components/ui/button" +import type { DriveFileInfo } from "@/lib/api/types" +import { useDriveSearchSuggestions } from "@/lib/api/hooks/use-drive-queries" +import { openDriveItem } from "@/lib/drive/drive-open-item" +import { + type DriveSearchScope, + buildDriveSearchUrl, + defaultDriveSearchScope, + fileBrowserViewForSearchScope, +} from "@/lib/drive/drive-search" +import type { DriveView } from "@/lib/drive/drive-url" +import { useDebouncedValue } from "@/lib/hooks/use-debounced-value" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { DRIVE_SEARCH_INPUT_WRAP_CLASS } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +interface DriveSearchBarProps { + value: string + onChange: (value: string) => void + scope: DriveSearchScope + onScopeChange: (scope: DriveSearchScope) => void + folderPath: string + contextView: DriveView + /** When true, suggestions panel hidden (search results page). */ + resultsMode?: boolean + className?: string + autoFocus?: boolean +} + +export function DriveSearchBar({ + value, + onChange, + scope, + onScopeChange, + folderPath, + contextView, + resultsMode = false, + className, + autoFocus = false, +}: DriveSearchBarProps) { + const router = useRouter() + const openPreview = useDriveUIStore((s) => s.openPreview) + const inputRef = useRef(null) + const [focused, setFocused] = useState(false) + const debouncedQuery = useDebouncedValue(value, 250) + const showFolderScope = folderPath !== "/" + const effectiveScope = + scope === "folder" && !showFolderScope + ? defaultDriveSearchScope(contextView, folderPath) + : scope + const searchPath = effectiveScope === "folder" ? folderPath : "/" + + const { data, isFetching } = useDriveSearchSuggestions( + debouncedQuery, + effectiveScope, + searchPath, + focused && !resultsMode && debouncedQuery.trim().length >= 2 + ) + + const suggestions = data?.files ?? [] + const showPanel = + !resultsMode && focused && value.trim().length >= 2 + + const submitSearch = (query?: string) => { + const q = (query ?? value).trim() + if (!q) return + router.push( + buildDriveSearchUrl({ + query: q, + scope: effectiveScope, + folderPath: effectiveScope === "folder" ? folderPath : "/", + }) + ) + inputRef.current?.blur() + } + + const openSuggestion = (item: DriveFileInfo) => { + openDriveItem(item, { + router, + openPreview, + view: fileBrowserViewForSearchScope(effectiveScope), + contextItems: suggestions, + }) + inputRef.current?.blur() + } + + return ( +
+
+
+ +
+ + onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => window.setTimeout(() => setFocused(false), 150)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submitSearch() + } + if (e.key === "Escape") { + onChange("") + inputRef.current?.blur() + } + }} + autoFocus={autoFocus} + className={cn( + "h-full w-full rounded-full border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground", + value ? "pl-11 pr-12" : "pl-11 pr-4" + )} + autoComplete="off" + /> + + {value && ( + + )} +
+ + {showPanel ? ( + submitSearch()} + /> + ) : null} +
+ ) +} diff --git a/components/drive/drive-search-breadcrumb.tsx b/components/drive/drive-search-breadcrumb.tsx new file mode 100644 index 0000000..749e9e1 --- /dev/null +++ b/components/drive/drive-search-breadcrumb.tsx @@ -0,0 +1,25 @@ +"use client" + +import { searchResultsTitle, type DriveSearchState } from "@/lib/drive/drive-search" +import { cn } from "@/lib/utils" + +const CRUMB_LINE_CLASS = "leading-6 md:leading-7" + +export function DriveSearchBreadcrumb({ search }: { search: DriveSearchState }) { + return ( + + ) +} diff --git a/components/drive/drive-search-suggestions.tsx b/components/drive/drive-search-suggestions.tsx new file mode 100644 index 0000000..2e34e43 --- /dev/null +++ b/components/drive/drive-search-suggestions.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useRouter } from "next/navigation" +import { FolderOpen, Loader2 } from "lucide-react" +import type { DriveFileInfo } from "@/lib/api/types" +import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon" +import { Button } from "@/components/ui/button" +import { + type DriveSearchScope, + DRIVE_SEARCH_SCOPES, + driveSearchScopeShortLabel, + itemLocationLabel, + itemParentFolderPath, +} from "@/lib/drive/drive-search" +import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree" +import { displayFileName } from "@/lib/drive/display-file-name" +import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes" +import { cn } from "@/lib/utils" + +function ScopePicker({ + scope, + onScopeChange, + showFolderScope, +}: { + scope: DriveSearchScope + onScopeChange: (scope: DriveSearchScope) => void + showFolderScope: boolean +}) { + const options = DRIVE_SEARCH_SCOPES.filter((s) => s !== "folder" || showFolderScope) + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +function SuggestionRow({ + item, + scope, + onPick, +}: { + item: DriveFileInfo + scope: DriveSearchScope + onPick: (item: DriveFileInfo) => void +}) { + const router = useRouter() + const view = scope === "shared" ? "shared" : "files" + const parentPath = itemParentFolderPath(item.path, item.type) + const parentHref = driveFolderHref(view, parentPath) + const location = itemLocationLabel(item.path, item.type) + + return ( +
+ + +
+ ) +} + +export function DriveSearchSuggestionsPanel({ + query, + scope, + onScopeChange, + showFolderScope, + suggestions, + loading, + onPickItem, + onSubmitSearch, + className, +}: { + query: string + scope: DriveSearchScope + onScopeChange: (scope: DriveSearchScope) => void + showFolderScope: boolean + suggestions: DriveFileInfo[] + loading: boolean + onPickItem: (item: DriveFileInfo) => void + onSubmitSearch: () => void + className?: string +}) { + const trimmed = query.trim() + if (trimmed.length < 2) return null + + return ( +
+ +
+ {loading ? ( +
+ + Recherche… +
+ ) : suggestions.length === 0 ? ( +

+ Aucune suggestion pour « {trimmed} » +

+ ) : ( + <> + {suggestions.map((item) => ( + + ))} + + + )} +
+
+ ) +} diff --git a/components/drive/drive-sidebar.tsx b/components/drive/drive-sidebar.tsx new file mode 100644 index 0000000..69532f1 --- /dev/null +++ b/components/drive/drive-sidebar.tsx @@ -0,0 +1,173 @@ +"use client" + +import { useMemo, useState } from "react" +import Link from "next/link" +import { useParams, usePathname } from "next/navigation" +import { Icon } from "@iconify/react" +import { Clock, Star, Trash2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { mailNavRowClass } from "@/lib/mail-chrome-classes" +import { DriveQuotaBar } from "@/components/drive/quota-bar" +import { DriveNewMenu } from "@/components/drive/new-menu" +import { DriveSidebarFolderTree } from "@/components/drive/sidebar-folder-tree" +import { AccountAvatar } from "@/components/suite/account-avatar" +import { AccountSwitcherSheet } from "@/components/suite/account-switcher-sheet" +import { Button } from "@/components/ui/button" +import { useIsXs } from "@/hooks/use-xs" +import { folderPathFromSegments, parseDriveSegments } from "@/lib/drive/drive-url" +import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" + +const OTHER_NAV = [ + { href: "/drive/recent", label: "Récents", icon: Clock }, + { href: "/drive/starred", label: "Favoris", icon: Star }, + { href: "/drive/trash", label: "Corbeille", icon: Trash2 }, +] + +export function DriveSidebar({ + overlay = false, + open = true, +}: { + overlay?: boolean + open?: boolean +}) { + const pathname = usePathname() + const params = useParams() + const isXs = useIsXs() + const identity = useChromeIdentity() + const [accountMenuOpen, setAccountMenuOpen] = useState(false) + const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed) + const route = useMemo( + () => parseDriveSegments(params.segments as string[] | undefined), + [params.segments] + ) + const parentPath = folderPathFromSegments(route.pathSegments) + const filesSegments = route.view === "files" ? route.pathSegments : [] + const sharedSegments = route.view === "shared" ? route.pathSegments : [] + + const closeSidebar = () => setSidebarCollapsed(true) + const displayName = identity?.name ?? "Utilisateur" + + return ( + + ) +} diff --git a/components/drive/drive-sort-menu.tsx b/components/drive/drive-sort-menu.tsx new file mode 100644 index 0000000..81c6260 --- /dev/null +++ b/components/drive/drive-sort-menu.tsx @@ -0,0 +1,133 @@ +"use client" + +import { ArrowDown, ArrowUp, Check } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + DRIVE_SORT_FIELD_LABELS, + driveSortOrderLabels, +} from "@/lib/drive/drive-sort" +import type { DriveFolderPlacement, DriveSortField } from "@/lib/stores/drive-settings-store" +import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store" +import { DRIVE_MENU_SURFACE_CLASS } from "@/components/drive/drive-file-context-menu" +import { cn } from "@/lib/utils" + +const MENU_SURFACE = cn( + DRIVE_MENU_SURFACE_CLASS, + "w-[min(100vw-2rem,20rem)] rounded-xl p-0" +) + +function SortSectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function SortMenuOption({ + label, + selected, + onSelect, +}: { + label: string + selected: boolean + onSelect: () => void +}) { + return ( + + ) +} + +function SortMenuDivider() { + return
+} + +const SORT_FIELDS: DriveSortField[] = ["name", "date", "size"] + +export function DriveSortMenu() { + const sortField = useDriveSettingsStore((s) => s.sortField) + const sortDir = useDriveSettingsStore((s) => s.sortDir) + const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement) + const setSortField = useDriveSettingsStore((s) => s.setSortField) + const setSortDir = useDriveSettingsStore((s) => s.setSortDir) + const setFolderPlacement = useDriveSettingsStore((s) => s.setFolderPlacement) + + const orderLabels = driveSortOrderLabels(sortField) + const triggerLabel = DRIVE_SORT_FIELD_LABELS[sortField] + + return ( + + + + + + Trier par + {SORT_FIELDS.map((field) => ( + setSortField(field)} + /> + ))} + + Ordre de tri + setSortDir("asc")} + /> + setSortDir("desc")} + /> + + Dossiers + setFolderPlacement("top")} + /> + setFolderPlacement("mixed")} + /> +
+ + + ) +} diff --git a/components/drive/drive-view-mode-toggle.tsx b/components/drive/drive-view-mode-toggle.tsx new file mode 100644 index 0000000..c42bb09 --- /dev/null +++ b/components/drive/drive-view-mode-toggle.tsx @@ -0,0 +1,46 @@ +"use client" + +import { Grid3X3, List } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store" +import { cn } from "@/lib/utils" + +export function DriveViewModeToggle() { + const viewMode = useDriveSettingsStore((s) => s.viewMode) + const setViewMode = useDriveSettingsStore((s) => s.setViewMode) + + return ( +
+ + +
+ ) +} diff --git a/components/drive/file-browser.tsx b/components/drive/file-browser.tsx new file mode 100644 index 0000000..a045205 --- /dev/null +++ b/components/drive/file-browser.tsx @@ -0,0 +1,162 @@ +"use client" + +import { useRouter } from "next/navigation" +import { FileThumbnail } from "@/components/drive/file-thumbnail" +import { DriveGridView } from "@/components/drive/drive-grid-view" +import { DriveListModified } from "@/components/drive/drive-list-modified" +import { DriveFileContextMenu } from "@/components/drive/drive-file-context-menu" +import type { DriveFileInfo } from "@/lib/api/types" +import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store" +import { openDriveItem } from "@/lib/drive/drive-open-item" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { + displayFileBaseName, + displayFileFormatLabel, +} from "@/lib/drive/display-file-name" +import { useDriveGridSelection } from "@/lib/hooks/use-drive-grid-selection" +import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes" +import { cn } from "@/lib/utils" + +function formatSize(n: number) { + if (n < 1024) return `${n} o` + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} Ko` + return `${(n / 1024 ** 2).toFixed(1)} Mo` +} + +export function FileBrowser({ + items, + view = "files", + isTrash, + onOpenItem, + mutations: mutationsProp, + allowShare: allowShareProp, + writable = true, + hideFavorite = false, + disableDnd = false, + onDownloadItem, + gridClassName, + publicShare, +}: { + items: DriveFileInfo[] + view?: "files" | "shared" + isTrash?: boolean + onOpenItem?: (file: DriveFileInfo) => void + mutations?: ReturnType + allowShare?: boolean + writable?: boolean + hideFavorite?: boolean + disableDnd?: boolean + onDownloadItem?: (file: DriveFileInfo) => void + gridClassName?: string + publicShare?: PublicShareThumbContext +}) { + const router = useRouter() + const viewMode = useDriveSettingsStore((s) => s.viewMode) + const openPreview = useDriveUIStore((s) => s.openPreview) + const mutationsDefault = useDriveMutations() + const mutations = mutationsProp ?? mutationsDefault + + const openItem = (file: DriveFileInfo) => { + if (onOpenItem) { + onOpenItem(file) + return + } + openDriveItem(file, { + router, + openPreview, + view, + contextItems: items, + isTrash: Boolean(isTrash), + }) + } + + const allowShare = allowShareProp ?? view !== "shared" + const { handleItemClick } = useDriveGridSelection(items, true) + + if (viewMode === "grid") { + return ( + + ) + } + + return ( +
+ +
+ {items.map((file) => ( + onDownloadItem(file) : undefined + } + onOpen={() => openItem(file)} + onItemClick={handleItemClick} + > +
+ + + {displayFileBaseName(file.name, file.type === "directory")} + + + {displayFileFormatLabel(file.name, file.type === "directory")} + + + {file.type === "directory" ? "—" : formatSize(file.size)} + + +
+
+ ))} +
+
+ ) +} diff --git a/components/drive/file-preview-dialog.tsx b/components/drive/file-preview-dialog.tsx new file mode 100644 index 0000000..d4f1415 --- /dev/null +++ b/components/drive/file-preview-dialog.tsx @@ -0,0 +1,610 @@ +"use client" + +import dynamic from "next/dynamic" +import { useRouter } from "next/navigation" +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react" +import { + ChevronLeft, + ChevronRight, + Copy, + Download, + ExternalLink, + FolderInput, + HardDrive, + Loader2, + Star, + Trash2, + UserPlus, + X, +} from "lucide-react" +import Link from "next/link" +import { toast } from "sonner" +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog" +import { downloadDriveFile, fetchDrivePreviewBlob } from "@/lib/api/drive-download" +import { fetchPublicShareBlob } from "@/lib/api/public-share" +import { downloadPublicShareFile } from "@/lib/drive/open-public-share-item" +import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries" +import { ApiRequestError } from "@/lib/api/client" +import { displayFileName } from "@/lib/drive/display-file-name" +import { + drivePreviewKind, + isSvgFile, + previewTargetToFileInfo, + type DrivePreviewKind, +} from "@/lib/drive/drive-preview" +import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer" +import { useDriveUIStore } from "@/lib/stores/drive-ui-store" +import { apiClient } from "@/lib/api/client" +import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker" +import { useSaveAttachmentToDrive } from "@/lib/api/hooks/use-mail-drive-save" +import { + mailDriveFileHref, + mailDriveFolderHref, + mailDriveFolderLabel, + mailDriveSaveErrorMessage, + mailDriveSaveSuccessMessage, +} from "@/lib/mail/mail-drive" +import { cn } from "@/lib/utils" + +const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 + +const PdfPreviewViewer = dynamic( + () => + import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer), + { + ssr: false, + loading: () => ( +
+ + Ouverture du PDF… +
+ ), + }, +) + +const PREVIEW_ACTION_BTN = + "cursor-pointer text-zinc-300 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-40" + +function PreviewActionButton({ + label, + onClick, + disabled, + className, + children, +}: { + label: string + onClick: () => void + disabled?: boolean + className?: string + children: ReactNode +}) { + return ( + + ) +} + +function PreviewBody({ + kind, + blobUrl, + name, + textContent, + svgMarkup, + onImageError, +}: { + kind: DrivePreviewKind + blobUrl: string + name: string + textContent?: string | null + svgMarkup?: string | null + onImageError?: () => void +}) { + if (kind === "text") { + return ( +
+        {textContent ?? ""}
+      
+ ) + } + if (svgMarkup) { + return + } + if (kind === "image") { + return ( + {displayFileName(name)} + ) + } + if (kind === "video") { + return ( + + ) + } + if (kind === "audio") { + return ( + + ) + } + return +} + +export function FilePreviewDialog() { + const previewFiles = useDriveUIStore((s) => s.previewFiles) + const previewIndex = useDriveUIStore((s) => s.previewIndex) + const previewContext = useDriveUIStore((s) => s.previewContext) + const closePreview = useDriveUIStore((s) => s.closePreview) + const stepPreview = useDriveUIStore((s) => s.stepPreview) + const setSharePath = useDriveUIStore((s) => s.setSharePath) + const updatePreviewFavorite = useDriveUIStore((s) => s.updatePreviewFavorite) + const removePreviewFile = useDriveUIStore((s) => s.removePreviewFile) + const mutations = useDriveMutations() + const [folderPickerMode, setFolderPickerMode] = useState(null) + const [mailSavePickerOpen, setMailSavePickerOpen] = useState(false) + + const file = previewIndex >= 0 ? (previewFiles[previewIndex] ?? null) : null + const fileInfo = useMemo(() => (file ? previewTargetToFileInfo(file) : null), [file]) + const allowShare = previewContext?.allowShare ?? true + const isTrash = previewContext?.isTrash ?? false + const publicShare = previewContext?.publicShare + const mailSource = previewContext?.mailSource ?? false + const mailMessageId = previewContext?.mailMessageId ?? file?.mailMessageId ?? "" + const router = useRouter() + const saveToDrive = useSaveAttachmentToDrive(mailMessageId) + const isMailAttachment = mailSource && Boolean(file?.mailAttachmentId) + const showWriteActions = !isTrash && !publicShare && !isMailAttachment + const mailDrivePath = isMailAttachment && file?.path.startsWith("/") ? file.path : undefined + const [blobUrl, setBlobUrl] = useState(null) + const [textContent, setTextContent] = useState(null) + const [svgMarkup, setSvgMarkup] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [imgFailed, setImgFailed] = useState(false) + const blobUrlRef = useRef(null) + + const kind = file ? drivePreviewKind(file) : null + const isSvg = file ? isSvgFile(file) : false + const hasPrev = previewIndex > 0 + const hasNext = previewIndex >= 0 && previewIndex < previewFiles.length - 1 + const positionLabel = + previewFiles.length > 1 ? `${previewIndex + 1} / ${previewFiles.length}` : null + + const revokeBlobUrl = () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = null + } + setBlobUrl(null) + } + + useEffect(() => { + if (!file || !kind) { + revokeBlobUrl() + setLoading(false) + setError(null) + setImgFailed(false) + setTextContent(null) + setSvgMarkup(null) + return + } + + let cancelled = false + + setLoading(true) + setError(null) + setImgFailed(false) + setTextContent(null) + setSvgMarkup(null) + + ;(async () => { + try { + const blob = file.mailAttachmentId + ? await apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`) + : publicShare + ? await fetchPublicShareBlob( + publicShare.token, + { path: file.path, name: file.name, mime_type: file.mime_type }, + publicShare.password + ) + : await fetchDrivePreviewBlob(file) + if (cancelled) return + if (kind === "text") { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = null + } + setBlobUrl(null) + setSvgMarkup(null) + if (blob.size > TEXT_PREVIEW_MAX_BYTES) { + setError("Fichier trop volumineux pour l’aperçu texte. Téléchargez-le.") + return + } + setTextContent(await blob.text()) + return + } + if (isSvg) { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = null + } + setBlobUrl(null) + setSvgMarkup(await blob.text()) + return + } + const url = URL.createObjectURL(blob) + const previous = blobUrlRef.current + blobUrlRef.current = url + setBlobUrl(url) + if (previous && previous !== url) { + URL.revokeObjectURL(previous) + } + } catch (err) { + if (!cancelled) { + const msg = + err instanceof ApiRequestError + ? err.message + : "Impossible de charger l’aperçu." + setError(msg) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [ + file?.path, + file?.mime_type, + file?.mailAttachmentId, + kind, + isSvg, + publicShare?.token, + publicShare?.password, + ]) + + const previewReady = + kind === "text" + ? textContent !== null + : isSvg + ? svgMarkup !== null + : Boolean(blobUrl) + + useEffect(() => { + if (previewFiles.length === 0) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault() + stepPreview(-1) + } else if (e.key === "ArrowRight") { + e.preventDefault() + stepPreview(1) + } + } + + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [previewFiles.length, stepPreview]) + + useEffect(() => { + return () => revokeBlobUrl() + }, []) + + const title = file ? displayFileName(file.name) : "" + const open = Boolean(file && kind) + + const onShare = () => { + if (!file) return + setSharePath(file.path, "file") + } + + const onToggleFavorite = async () => { + if (!file) return + const next = !file.is_favorite + try { + await mutations.favorite.mutateAsync({ path: file.path, favorite: next }) + updatePreviewFavorite(file.path, next) + toast.success(next ? "Ajouté aux favoris" : "Retiré des favoris") + } catch { + toast.error("Impossible de modifier les favoris") + } + } + + const onDelete = async () => { + if (!file) return + try { + await mutations.deleteFile.mutateAsync(file.path) + removePreviewFile(file.path) + toast.success("Supprimé") + } catch { + toast.error("Impossible de supprimer") + } + } + + const favoriteLabel = file?.is_favorite ? "Retirer des favoris" : "Ajouter aux favoris" + + const onMailDownload = () => { + if (!file?.mailAttachmentId) return + void apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`).then((blob) => { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = displayFileName(file.name) + a.click() + URL.revokeObjectURL(url) + }) + } + + const onMailSaveToDrive = async (folderPath: string) => { + if (!file?.mailAttachmentId || !mailMessageId) return + try { + const drivePath = await saveToDrive.mutateAsync({ + attachmentId: file.mailAttachmentId, + folderPath, + }) + useDriveUIStore.setState((state) => ({ + previewFiles: state.previewFiles.map((f) => + f.mailAttachmentId === file.mailAttachmentId + ? { ...f, path: drivePath } + : f + ), + })) + setMailSavePickerOpen(false) + toast.success(mailDriveSaveSuccessMessage(folderPath), { + action: { + label: "Ouvrir le dossier", + onClick: () => router.push(mailDriveFolderHref(folderPath)), + }, + }) + } catch (err) { + toast.error(mailDriveSaveErrorMessage(err)) + } + } + + return ( + <> + + { + if (!nextOpen) setFolderPickerMode(null) + }} + mode={folderPickerMode ?? "move"} + sources={fileInfo ? [fileInfo] : []} + onMoved={() => { + if (folderPickerMode === "move" && file) removePreviewFile(file.path) + setFolderPickerMode(null) + }} + /> + { + if (!nextOpen) closePreview() + }} + > + +
+ + {title} + + {positionLabel ? ( + {positionLabel} + ) : null} + {file ? ( +
+ {showWriteActions && allowShare ? ( + + + + ) : null} + {showWriteActions ? ( + void onToggleFavorite()} + disabled={mutations.favorite.isPending} + className={cn( + file.is_favorite && "text-amber-400 hover:bg-amber-400/10 hover:text-amber-300" + )} + > + + + ) : null} + {showWriteActions ? ( + <> + setFolderPickerMode("move")} + > + + + setFolderPickerMode("copy")} + className="max-sm:hidden" + > + + + + ) : null} + {isMailAttachment && !mailDrivePath ? ( + setMailSavePickerOpen(true)} + disabled={saveToDrive.isPending} + > + + + ) : null} + {isMailAttachment && mailDrivePath ? ( + {}} + disabled + className="!w-auto max-w-[min(40vw,16rem)] px-2 opacity-100" + > + e.stopPropagation()} + > + + + {mailDriveFolderLabel(mailDrivePath)} + + + + ) : null} + + void (isMailAttachment + ? onMailDownload() + : publicShare + ? downloadPublicShareFile( + publicShare.token, + { path: file.path, name: file.name, mime_type: file.mime_type, type: "file", size: 0, last_modified: "", is_favorite: false, is_shared: false }, + publicShare.password + ) + : downloadDriveFile(file.path, file.name, file.name)) + } + > + + + {showWriteActions ? ( + void onDelete()} + disabled={mutations.deleteFile.isPending} + className="hover:bg-red-500/10 hover:text-red-400" + > + + + ) : null} + + + +
+ ) : null} +
+ +
+ {hasPrev ? ( + + ) : null} + {hasNext ? ( + + ) : null} + +
+ {loading ? ( +
+ + Chargement… +
+ ) : null} + {!loading && error ? ( +

{error}

+ ) : null} + {!loading && !error && imgFailed ? ( +

+ Aperçu non pris en charge par le navigateur (ex. HEIC). Téléchargez le fichier. +

+ ) : null} + {!loading && !error && !imgFailed && kind && file && previewReady ? ( + setImgFailed(true)} + /> + ) : null} +
+
+
+
+ + ) +} diff --git a/components/drive/file-thumbnail.tsx b/components/drive/file-thumbnail.tsx new file mode 100644 index 0000000..4c58e95 --- /dev/null +++ b/components/drive/file-thumbnail.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { Play } from "lucide-react" +import type { DriveFileInfo } from "@/lib/api/types" +import { useDrivePreviewThumb } from "@/lib/api/hooks/use-drive-preview-thumb" +import { + usePublicSharePreviewThumb, + type PublicShareThumbContext, +} from "@/lib/api/hooks/use-public-share-preview-thumb" +import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon" +import { + drivePreviewKind, + driveServerThumbnail, + isOfficeFormat, +} from "@/lib/drive/drive-preview" +import { cn } from "@/lib/utils" + +function useInView(rootMargin = "200px") { + const ref = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setInView(true) + observer.disconnect() + } + }, + { rootMargin } + ) + + observer.observe(el) + return () => observer.disconnect() + }, [rootMargin]) + + return { ref, inView } +} + +function PreviewContent({ + url, + display, + darkInvert, + onError, +}: { + url: string + display: "image" | "video" + darkInvert?: boolean + onError: () => void +}) { + if (display === "video") { + return ( + <> +