Add OnlyOffice integration and update project configurations

- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
This commit is contained in:
R3D347HR4Y 2026-06-07 15:49:21 +02:00
parent 07d57f13a8
commit 6ec95262af
207 changed files with 17387 additions and 688 deletions

View File

@ -13,3 +13,6 @@ NEXT_PUBLIC_APP_URL=http://localhost
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint # Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
OIDC_CLIENT_SECRET=changeme OIDC_CLIENT_SECRET=changeme
# OnlyOffice Document Server (UltiDrive editor)
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office

View File

@ -1,8 +1,11 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = { export const metadata: Metadata = suitePageMetadata({
title: "Contacts - Ultimail", app: "contacts",
} absoluteTitle: true,
title: "Contacts - Ulti Suite",
})
export default function ContactsLayout({ export default function ContactsLayout({
children, children,

View File

@ -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<Metadata> {
const { segments } = await params
return suitePageMetadata({
app: "drive",
titleSegment: driveDocumentTitle(segments),
})
}
export default function DriveSegmentsLayout({ children }: LayoutProps) {
return children
}

View File

@ -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<DriveSearchScope>(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 (
<>
<DriveHeader
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
/>
<div
className={cn(
"flex min-h-0 flex-1 flex-col pb-1 max-sm:pb-0",
DRIVE_MAIN_INSET_X
)}
>
<div className={DRIVE_BROWSER_CARD_CLASS} data-drive-browser-card>
<DriveBrowserChrome
view={route.view}
segments={route.pathSegments}
isTrash={isTrash}
items={filteredFiles}
searchState={committedSearch}
/>
<main
data-drive-browser-main
className="flex min-h-0 flex-1 flex-col overflow-auto"
>
<DriveMarqueeSurface
enabled={!isLoading && !active.isError && filteredFiles.length > 0}
className="min-h-full"
>
{isLoading && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Chargement
</p>
)}
{active.isError && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-destructive"
)}
>
{isSearchView
? "Impossible de charger les résultats de recherche."
: "Impossible de charger les fichiers."}
</p>
)}
{!isLoading && !active.isError && files.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
{emptyMessage}
</p>
)}
{!isLoading && !active.isError && files.length > 0 && filteredFiles.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Aucun élément ne correspond aux filtres.
</p>
)}
{filteredFiles.length > 0 ? (
<FileBrowser
items={filteredFiles}
view={isSearchView ? searchBrowserView : route.view === "shared" ? "shared" : "files"}
isTrash={isTrash}
/>
) : null}
<DriveScrollEndSpacer />
</DriveMarqueeSurface>
</main>
</div>
</div>
<DriveMobileBottomBar
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
resultsMode={isSearchView}
parentPath={folderPath}
/>
</>
)
}

View File

@ -0,0 +1,5 @@
import { DriveAppShell } from "@/components/drive/drive-app-shell"
export default function DriveBrowserLayout({ children }: { children: React.ReactNode }) {
return <DriveAppShell>{children}</DriveAppShell>
}

View File

@ -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<Metadata> {
const { fileId } = await params
const name = displayFileName(decodeURIComponent(fileId))
return suitePageMetadata({
app: "drive",
titleSegment: name,
})
}
export default function EditLayout({ children }: LayoutProps) {
return <>{children}</>
}

View File

@ -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 <OfficeEditor filePath={filePath} returnTo={returnTo} />
}

8
app/drive/layout.tsx Normal file
View File

@ -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
}

View File

@ -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<string | undefined>(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 (
<PublicShareChrome>
{isLoading || (isFetching && !data) ? (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : needsPassword ? (
<div className="mx-auto flex min-h-[40vh] max-w-md flex-col justify-center px-4 py-12">
<div className="rounded-2xl border border-border bg-mail-surface p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2 text-[#3c4043] dark:text-[#e8eaed]">
<Lock className="h-5 w-5" />
<h1 className="text-lg font-medium">Lien protégé par mot de passe</h1>
</div>
<p className="mb-4 text-sm text-muted-foreground">
Saisissez le mot de passe pour accéder à ce partage.
</p>
<form className="flex flex-col gap-3" onSubmit={submitPassword}>
<Input
type="password"
autoComplete="current-password"
placeholder="Mot de passe"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
/>
<Button type="submit">Accéder</Button>
</form>
</div>
</div>
) : isError || !data ? (
<div className="mx-auto max-w-lg px-4 py-16 text-center">
<h1 className="text-lg font-medium text-[#3c4043] dark:text-[#e8eaed]">
Partage indisponible
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Ce lien est expiré, révoqué ou incorrect.
</p>
<Button type="button" variant="outline" className="mt-6" onClick={() => void refetch()}>
Réessayer
</Button>
</div>
) : (
<PublicShareViewPanel token={token} path={path} data={data} password={password} />
)}
</PublicShareChrome>
)
}

View File

@ -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<string | undefined>(() => {
if (typeof window === "undefined") return undefined
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
})
return (
<PublicOfficeEditor
token={token}
filePath={filePath}
password={password}
returnTo={returnTo}
mode={mode}
/>
)
}

12
app/drive/s/layout.tsx Normal file
View File

@ -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
}

View File

@ -1,5 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import '../styles/onlyoffice-theme.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@ -63,6 +64,11 @@
--mail-list-chip-text: #3c4043; --mail-list-chip-text: #3c4043;
--mail-list-chip-muted: #f1f3f4; --mail-list-chip-muted: #f1f3f4;
--mail-row-checkbox-border: #c2c2c2; --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 { .dark {
@ -91,6 +97,11 @@
--mail-list-chip-text: #e8eaed; --mail-list-chip-text: #e8eaed;
--mail-list-chip-muted: #3c4043; --mail-list-chip-muted: #3c4043;
--mail-row-checkbox-border: #9aa0a6; --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); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0); --card: oklch(0.145 0 0);
@ -186,6 +197,13 @@
--color-mail-surface: var(--mail-surface); --color-mail-surface: var(--mail-surface);
--color-mail-surface-elevated: var(--mail-surface-elevated); --color-mail-surface-elevated: var(--mail-surface-elevated);
--color-mail-surface-muted: var(--mail-surface-muted); --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: var(--mail-border);
--color-mail-border-subtle: var(--mail-border-subtle); --color-mail-border-subtle: var(--mail-border-subtle);
--color-mail-invitation: var(--mail-invitation); --color-mail-invitation: var(--mail-invitation);
@ -194,6 +212,9 @@
--color-mail-list-chip-text: var(--mail-list-chip-text); --color-mail-list-chip-text: var(--mail-list-chip-text);
--color-mail-list-chip-muted: var(--mail-list-chip-muted); --color-mail-list-chip-muted: var(--mail-list-chip-muted);
--color-mail-row-checkbox-border: var(--mail-row-checkbox-border); --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 { @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; 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 { .ultimail-app {
position: relative; position: relative;
isolation: isolate; 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) ── */ /* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
html.dark .ultimail-app { html.dark .ultimail-app {
color-scheme: dark; 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) { html.dark .ultimail-app :where(.bg-white) {
@ -767,7 +859,7 @@ html.dark .ultimail-app :where(.bg-\[\#e8f0fe\]) {
background-color: var(--mail-active) !important; 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; 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; 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']:focus,
html.dark [data-slot='dropdown-menu-item'][data-highlighted], html.dark [data-slot='dropdown-menu-item'][data-highlighted],
html.dark [data-slot='dropdown-menu-sub-trigger']:focus, 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) { html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) {
border-color: var(--border) !important; 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;
}

View File

@ -7,15 +7,12 @@ import { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider' import { QueryProvider } from '@/lib/api/query-provider'
import { AuthProvider } from '@/components/auth/auth-provider' import { AuthProvider } from '@/components/auth/auth-provider'
import { MailToaster } from '@/components/gmail/mail-toaster' import { MailToaster } from '@/components/gmail/mail-toaster'
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = suiteRootMetadata()
title: 'Ultimail',
description: 'Interface client mail Ultimail (clone UI) construite avec React',
generator: 'v0.app',
}
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */ /** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
export const viewport: Viewport = { export const viewport: Viewport = {

View File

@ -1,4 +1,11 @@
import { LoginChrome } from "@/components/auth/login-chrome" 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({ export default function LoginLayout({
children, children,

View File

@ -1,4 +1,12 @@
import { MailAppShell } from "./mail-app-shell" 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({ export default function MailLayout({
children, children,

View File

@ -11,6 +11,8 @@ import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view" import { useMailSplitView } from "@/hooks/use-mail-split-view"
import { useMailRoute } from "@/hooks/use-mail-route" 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 { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" 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 { ComposeModalManager } from "@/components/gmail/compose-modal"
import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
import { mailNavVisitKey } from "@/lib/mail-folder-display" import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { MailDocumentTitle } from "@/components/gmail/mail-document-title"
import { useMailStore } from "@/lib/stores/mail-store" import { useMailStore } from "@/lib/stores/mail-store"
import { useMailUiStore } from "@/lib/stores/mail-ui-store" import { useMailUiStore } from "@/lib/stores/mail-ui-store"
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url" 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 { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge"
import { useWebSocket } from "@/lib/api/ws" import { useWebSocket } from "@/lib/api/ws"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
const MAIL_SETTINGS_PATH = "/mail/settings" const MAIL_SETTINGS_PATH = "/mail/settings"
@ -52,6 +56,10 @@ function MailAppInner() {
const router = useRouter() const router = useRouter()
const { route, navigateRoute, searchParams: currentSearchParams } = const { route, navigateRoute, searchParams: currentSearchParams } =
useMailRoute() useMailRoute()
const activeSearchQuery =
route.folderId === "search"
? searchParamsToDisplayQuery(parseSearchParams(currentSearchParams))
: ""
const isXs = useIsXs() const isXs = useIsXs()
const touchNav = useTouchNav() const touchNav = useTouchNav()
@ -108,6 +116,7 @@ function MailAppInner() {
}) })
}} }}
> >
<MailDocumentTitle />
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"> <div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
{!splitView ? ( {!splitView ? (
<div className="hidden sm:block"> <div className="hidden sm:block">
@ -191,14 +200,14 @@ function MailAppInner() {
} }
xsViewChrome={xsViewChrome} xsViewChrome={xsViewChrome}
onOpenSearch={() => setMobileSearchOpen(true)} onOpenSearch={() => setMobileSearchOpen(true)}
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} searchQuery={activeSearchQuery}
onClearSearch={() => router.push("/mail/inbox")} onClearSearch={() => router.push("/mail/inbox")}
/> />
) : null} ) : null}
<MobileSearchOverlay <MobileSearchOverlay
open={mobileSearchOpen} open={mobileSearchOpen}
onClose={() => setMobileSearchOpen(false)} onClose={() => setMobileSearchOpen(false)}
initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} initialQuery={activeSearchQuery}
/> />
</div> </div>
</SidebarNavProvider> </SidebarNavProvider>
@ -260,6 +269,7 @@ export function MailAppShell({
<QuickSettingsRoot /> <QuickSettingsRoot />
<MoveDragIndicator /> <MoveDragIndicator />
<ComposeModalManager /> <ComposeModalManager />
<FilePreviewDialog />
</EmailDragProvider> </EmailDragProvider>
</ScheduledMailProvider> </ScheduledMailProvider>
</ComposeProvider> </ComposeProvider>

View File

@ -1,4 +1,11 @@
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout" 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({ export default function MailSettingsRootLayout({
children, children,

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useState, type ReactNode } from "react" import { useCallback, useEffect, useState, type ReactNode } from "react"
import { usePathname, useRouter } from "next/navigation" 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 { isOidcConfigured } from "@/lib/auth/oidc-config"
import type { PlatformUser } from "@/lib/auth/jwt-claims" 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 const REFRESH_CHECK_MS = 60 * 1000
function isPublicPath(pathname: string) { function isPublicPath(pathname: string) {
if (pathname.startsWith("/drive/s/")) return true
return PUBLIC_PREFIXES.some( return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix) (prefix) => pathname === prefix || pathname.startsWith(prefix)
) )
@ -159,7 +160,10 @@ export function useAuthLogout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" }) await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
logout() logout()
if (typeof window !== "undefined") { 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") router.replace("/login")
} }

View File

@ -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<DriveView, "files" | "shared">
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<DriveFolderPickerMode | null>(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 = (
<>
<DriveNameDialog
open={renameDialogOpen}
onOpenChange={setRenameDialogOpen}
title="Renommer le dossier"
defaultValue={label}
confirmLabel="Renommer"
onConfirm={handleRename}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(next) => {
if (!next) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={targets}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="bottom"
hideClose
className="gap-0 overflow-hidden rounded-t-2xl border-border px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2"
>
<SheetTitle className="sr-only">Actions pour {label}</SheetTitle>
<SheetDescription className="sr-only">
Actions disponibles pour {label}.
</SheetDescription>
<p className="truncate px-4 pb-2 text-sm font-medium text-muted-foreground">
{label}
</p>
<div className="flex flex-col border-t border-border">
<DriveFileMenuActions
variant="sheet"
{...menuActionsProps}
onClose={() => setSheetOpen(false)}
onRenameRequest={() => {
setSheetOpen(false)
openRenameDialog()
}}
/>
</div>
</SheetContent>
</Sheet>
</>
)
if (isMobile) {
return (
<>
<button
type="button"
data-drive-menu-btn
aria-label={`Actions pour ${label}`}
aria-expanded={sheetOpen}
aria-haspopup="dialog"
className={cn(DRIVE_MENU_BTN, className)}
onClick={(e) => {
stopDriveMenuBubble(e)
setSheetOpen(true)
}}
onPointerDown={(e) => stopDriveMenuBubble(e)}
>
<MoreVertical className="h-4 w-4" aria-hidden />
</button>
{dialogs}
</>
)
}
return (
<>
<DropdownMenu
modal
open={dropdownOpen}
onOpenChange={(next) => {
if (next) {
setDropdownOpen(true)
return
}
closeDropdown()
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
data-drive-menu-btn
aria-label={`Actions pour ${label}`}
aria-haspopup="menu"
className={cn(
DRIVE_MENU_BTN,
dropdownOpen && DRIVE_MENU_BTN_ACTIVE,
className
)}
onClick={(e) => stopDriveMenuBubble(e)}
onPointerDown={(e) => stopDriveMenuBubble(e)}
>
<MoreVertical className="h-4 w-4" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
data-drive-menu-surface
className={cn(DRIVE_MENU_SURFACE_CLASS, "w-52")}
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerDown={(e) => stopDriveMenuBubble(e)}
onClick={(e) => e.stopPropagation()}
>
<DriveFileMenuActions
variant="dropdown"
{...menuActionsProps}
onClose={closeDropdown}
onRenameRequest={() => {
closeDropdown()
openRenameDialog()
}}
/>
</DropdownMenuContent>
</DropdownMenu>
{dialogs}
</>
)
}

View File

@ -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<DriveView, "files" | "shared">
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 (
<>
<nav className="flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden text-[#5f6368] md:gap-2 dark:text-muted-foreground">
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1
const isRenamableFolder = isLast && canRenameCurrent
return (
<Fragment key={c.href}>
{i > 0 && (
<ChevronRight
className="size-4 shrink-0 opacity-60 md:size-5"
aria-hidden
/>
)}
{isLast ? (
<span className="inline-flex min-w-0 max-w-full shrink items-center gap-0.5">
<span
className={cn(
"min-w-0 max-w-full shrink truncate text-base font-normal tracking-tight text-[#202124] md:text-[1.375rem] dark:text-foreground",
CRUMB_LINE_CLASS,
isRenamableFolder &&
"cursor-text rounded-sm px-0.5 hover:bg-mail-nav-hover"
)}
title={
isRenamableFolder
? `${c.label} — double-clic pour renommer`
: c.label
}
onDoubleClick={
isRenamableFolder
? (e) => {
e.preventDefault()
setRenameOpen(true)
}
: undefined
}
>
{c.label}
</span>
{segments.length > 0 ? (
<BreadcrumbFolderMenu
view={view}
segments={segments}
folderPath={folderPath}
writable={writable}
allowShare={view === "files"}
renameOpen={renameOpen}
onRenameOpenChange={setRenameOpen}
/>
) : null}
</span>
) : (
<Link
href={c.href}
title={c.label}
className={cn(
"inline-flex shrink-0 cursor-pointer text-sm hover:text-[#202124] md:text-[1.125rem] dark:hover:text-foreground",
CRUMB_LINE_CLASS,
MOBILE_INTERMEDIATE_CRUMB_CLASS,
i === 0 ? "max-md:max-w-[5.5rem]" : "max-md:max-w-[4.5rem]"
)}
>
{c.label}
</Link>
)}
</Fragment>
)
})}
<span className="sr-only">{folderPath}</span>
</nav>
</>
)
}

View File

@ -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 (
<SuiteThemeShell>
<div className="ultimail-app relative flex h-dvh overflow-hidden bg-app-canvas" data-drive-app>
{isMobile && sidebarOpen && (
<button
type="button"
aria-label="Fermer le menu"
className="absolute inset-0 z-40 bg-black/20"
onClick={() => setSidebarCollapsed(true)}
/>
)}
<DriveSidebar overlay={isMobile} open={sidebarOpen} />
<div className="flex min-w-0 flex-1 flex-col bg-app-canvas" data-drive-main-column>{children}</div>
<ShareDialog />
<FilePreviewDialog />
</div>
</SuiteThemeShell>
)
}

View File

@ -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<Record<DriveView, string>> = {
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 (
<div className={cn("shrink-0", DRIVE_FILTER_CONTENT_GAP)}>
<div className={cn("flex min-h-12 shrink-0 items-center justify-between gap-4 py-1", DRIVE_CARD_PAD_X)}>
<div className="min-w-0 flex-1">
{showSearchBreadcrumb ? (
<DriveSearchBreadcrumb search={searchState} />
) : showBreadcrumb ? (
<BreadcrumbNav
view={view}
segments={segments}
writable={view === "files"}
/>
) : title ? (
<div className="flex min-w-0 items-center gap-3">
<p className="text-[1.375rem] font-normal leading-snug tracking-tight text-[#202124] dark:text-foreground">
{title}
</p>
{showEmptyTrash ? (
<button
type="button"
className="shrink-0 text-sm font-medium text-[#1a73e8] hover:underline dark:text-[#8ab4f8]"
onClick={() => void onEmptyTrash()}
disabled={mutations.emptyTrash.isPending}
>
Vider la corbeille
</button>
) : null}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<DriveSortMenu />
<DriveViewModeToggle />
</div>
</div>
{showBulk ? (
<DriveBulkToolbar
targets={selectedTargets}
isTrash={isTrash}
allowShare={allowShare}
/>
) : (
<DriveFilterBar showContacts={view !== "trash"} />
)}
</div>
)
}

View File

@ -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 (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("h-8 w-8 shrink-0 rounded-full", DRIVE_ICON_BTN, className)}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
)
}
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<typeof useDriveMutations>
onDownloadBulk?: (files: DriveFileInfo[]) => Promise<void>
}) {
const clearSelection = useDriveUIStore((s) => s.clearSelection)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const mutationsDefault = useDriveMutations()
const mutations = mutationsProp ?? mutationsDefault
const [folderPickerMode, setFolderPickerMode] = useState<DriveFolderPickerMode | null>(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<void>, 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 (
<>
<DriveMoveDialog
open={folderPickerMode !== null && (allowMove || allowCopy)}
onOpenChange={(open) => {
if (!open) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={targets}
onMoved={clearSelection}
/>
<div className={cn("py-1.5", DRIVE_CARD_PAD_X)}>
<div
role="toolbar"
aria-label="Actions de sélection"
className="flex h-8 shrink-0 items-center gap-2 rounded-full bg-[#edf2fc] dark:bg-primary/15"
>
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<BulkIconButton label="Désélectionner" onClick={clearSelection}>
<X className="h-5 w-5" strokeWidth={1.75} />
</BulkIconButton>
<span className="whitespace-nowrap px-1 text-sm font-medium text-[#444746] dark:text-[#e8eaed]">
{n} sélectionné{n > 1 ? "s" : ""}
</span>
</div>
<div className="ml-auto flex shrink-0 items-center gap-0.5">
{isTrash ? (
<>
<BulkIconButton label="Restaurer" onClick={onRestore}>
<Undo2 className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
<BulkIconButton
label="Supprimer définitivement"
onClick={onPermanentDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
</>
) : (
<>
{allowShare ? (
<BulkIconButton
label="Partager"
onClick={onShare}
disabled={!canShare}
>
<UserPlus className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
) : null}
<BulkIconButton label="Télécharger" onClick={() => void onDownload()}>
<Download className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
{allowCopy ? (
<BulkIconButton
label="Copier vers"
className="max-sm:hidden"
onClick={() => setFolderPickerMode("copy")}
>
<Copy className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
) : null}
{allowMove ? (
<BulkIconButton
label="Déplacer vers"
onClick={() => setFolderPickerMode("move")}
>
<FolderInput className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
) : null}
{allowDelete ? (
<BulkIconButton
label="Supprimer"
onClick={onDelete}
className="text-[#444746] hover:text-destructive dark:text-[#e8eaed]"
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
) : null}
{allowQuickLink ? (
<BulkIconButton
label="Obtenir le lien"
className="max-sm:hidden"
onClick={() => void onQuickLink()}
disabled={!single}
>
<Link2 className="h-[18px] w-[18px]" strokeWidth={1.75} />
</BulkIconButton>
) : null}
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("h-8 w-8 shrink-0 rounded-full", DRIVE_ICON_BTN)}
aria-label="Plus d'actions"
>
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.75} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
data-drive-menu-surface
className={cn(DRIVE_MENU_SURFACE_CLASS, "w-52")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DriveFileMenuActions
variant="dropdown"
targets={targets}
isTrash={isTrash}
allowShare={allowShare}
onOpen={noopOpen}
onClose={() => 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)
}
}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,36 @@
"use client"
import { createContext, useContext } from "react"
type RegisterCardRef = (path: string, el: HTMLDivElement | null) => void
const DriveCardRefContext = createContext<RegisterCardRef | null>(null)
export function DriveCardRefProvider({
registerCardRef,
children,
}: {
registerCardRef: RegisterCardRef
children: React.ReactNode
}) {
return (
<DriveCardRefContext.Provider value={registerCardRef}>
{children}
</DriveCardRefContext.Provider>
)
}
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)
}
}

View File

@ -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<typeof useDriveMutations>
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<DriveFolderPickerMode | null>(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 (
<>
<DriveNameDialog
open={renameOpen}
onOpenChange={setRenameOpen}
title="Renommer"
defaultValue={renameTarget ? displayFileName(renameTarget.name) : ""}
confirmLabel="Renommer"
onConfirm={handleRename}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(next) => {
if (!next) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={targets}
/>
<DropdownMenu
modal
open={open}
onOpenChange={(next) => {
if (next) {
setOpen(true)
onActiveChange?.(true)
return
}
closeDropdown()
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
data-drive-menu-btn
aria-label="Actions"
className={cn(DRIVE_MENU_BTN, className)}
onClick={(e) => stopDriveMenuBubble(e)}
onPointerDown={(e) => stopDriveMenuBubble(e)}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-drive-menu-surface
className={cn(DRIVE_MENU_SURFACE_CLASS, "w-52")}
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerDown={(e) => stopDriveMenuBubble(e)}
onClick={(e) => e.stopPropagation()}
>
<DriveFileMenuActions
variant="dropdown"
targets={targets}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
onOpen={() => {
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}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@ -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<typeof useDriveMutations>
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<DriveFolderPickerMode | null>(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 = (
<div
ref={mergedRegisterRef}
data-drive-card
data-path={file.path}
className={cn(
className,
!isGrid && isMobile && longPress.ackClassName,
isFolder && isOver && canDrop && DRIVE_DROP_TARGET_CLASS,
isSelected && DRIVE_CARD_HIGHLIGHT_CLASS
)}
onClick={
isMobile
? handleMobileTap
: onItemClick
? handleGridClick
: handleClickSelect
}
onDoubleClick={isMobile ? undefined : onItemClick ? handleOpenDoubleClick : undefined}
{...dragProps}
{...dropProps}
{...(!isGrid && isMobile
? {
...touchProps,
onPointerDown: handlePointerDownSelect,
}
: isMobile
? touchProps
: {})}
>
{children}
</div>
)
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 = (
<>
<DriveNameDialog
open={renameOpen}
onOpenChange={setRenameOpen}
title="Renommer"
defaultValue={renameTarget ? displayFileName(renameTarget.name) : ""}
confirmLabel="Renommer"
onConfirm={handleRename}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(open) => {
if (!open) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={targets}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="bottom"
hideClose
className="gap-0 overflow-hidden rounded-t-2xl border-border px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2"
>
<SheetTitle className="sr-only">
{targets.length > 1
? `${targets.length} éléments`
: displayFileName(file.name)}
</SheetTitle>
<SheetDescription className="sr-only">
{targets.length > 1
? `Actions pour ${targets.length} éléments sélectionnés.`
: `Actions pour ${displayFileName(file.name)}.`}
</SheetDescription>
<p className="truncate px-4 pb-2 text-sm font-medium text-muted-foreground">
{targets.length > 1
? `${targets.length} éléments sélectionnés`
: displayFileName(file.name)}
</p>
<div className="flex flex-col border-t border-border">
<DriveFileMenuActions
variant="sheet"
{...menuActionsProps}
onClose={() => setSheetOpen(false)}
onRenameRequest={() => {
setSheetOpen(false)
openRenameDialog()
}}
/>
</div>
</SheetContent>
</Sheet>
</>
)
if (isMobile) {
return (
<>
{trigger}
{menus}
</>
)
}
const contextMenu = (
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
<ContextMenuContent
data-drive-menu-surface
className={cn(DRIVE_MENU_SURFACE_CLASS, "w-52")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DriveFileMenuActions
variant="context"
{...menuActionsProps}
onClose={closeContextMenu}
/>
</ContextMenuContent>
</ContextMenu>
)
return (
<>
{contextMenu}
{menus}
</>
)
}

View File

@ -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 (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{children}
</span>
)
}
type Mutations = ReturnType<typeof useDriveMutations>
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<void>, ok: string, err: string) => {
onClose?.()
try {
await fn()
toast.success(ok)
} catch {
toast.error(err)
}
}
const runMenuAction = (
fn: () => void,
asyncFn?: () => Promise<void>,
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: <ExternalLink className="h-4 w-4" aria-hidden />,
visible: !hideOpen && !multi && Boolean(single),
onSelect: () => runMenuAction(() => window.setTimeout(() => onOpen(), 0)),
},
{
key: "select",
label: "Sélectionner",
icon: <CheckSquare className="h-4 w-4" aria-hidden />,
visible: variant === "sheet" && Boolean(!isTrash && single && onEnterSelectionMode),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onEnterSelectionMode?.(), 0)
}),
},
{
key: "share",
label: "Partager",
icon: <Link2 className="h-4 w-4" aria-hidden />,
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: <Copy className="h-4 w-4" aria-hidden />,
visible: Boolean(writable && !isTrash && onCopyRequest),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onCopyRequest?.(), 0)
}),
},
{
key: "move",
label: multi ? `Déplacer vers (${targets.length})` : "Déplacer vers",
icon: <FolderInput className="h-4 w-4" aria-hidden />,
visible: Boolean(writable && !isTrash && onMoveRequest),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onMoveRequest?.(), 0)
}),
},
{
key: "download",
label: "Télécharger",
icon: <Download className="h-4 w-4" aria-hidden />,
visible: Boolean(!isTrash && onDownloadRequest),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onDownloadRequest?.(), 0)
}),
},
{
key: "quick-link",
label: "Obtenir le lien",
icon: <Link2 className="h-4 w-4" aria-hidden />,
visible: Boolean(!isTrash && onQuickLinkRequest && single),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onQuickLinkRequest?.(), 0)
}),
},
{
key: "favorite",
label: favoriteLabel,
icon: <Star className="h-4 w-4" aria-hidden />,
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: <Undo2 className="h-4 w-4" aria-hidden />,
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: <Trash2 className="h-4 w-4" aria-hidden />,
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: <Pencil className="h-4 w-4" aria-hidden />,
visible: Boolean(writable && !isTrash && single && !multi),
onSelect: () =>
runMenuAction(() => {
window.setTimeout(() => onRenameRequest(), 0)
}),
},
{
key: "delete",
label: `Supprimer${multi ? ` (${targets.length})` : ""}`,
icon: <Trash2 className="h-4 w-4" aria-hidden />,
destructive: true,
visible: writable && !isTrash,
onSelect: () =>
runMenuAction(() => {}, deleteAsync, "Supprimé", "Impossible de supprimer"),
},
]
if (variant === "sheet") {
return (
<>
{actions
.filter((action) => action.visible)
.map((action) => (
<button
key={action.key}
type="button"
className={cn(
SHEET_ACTION_CLASS,
action.destructive &&
"text-destructive hover:bg-destructive/10 active:bg-destructive/15 [&_svg]:text-destructive"
)}
onPointerDown={(e) => stopDriveMenuEvent(e)}
onClick={() => {
guardDriveMenuPointer()
action.onSelect()
}}
>
<DriveMenuItemIcon>{action.icon}</DriveMenuItemIcon>
{action.label}
</button>
))}
</>
)
}
if (variant === "context") {
return (
<>
{actions
.filter((action) => action.visible)
.map((action) => (
<ContextMenuItem
key={action.key}
variant={action.destructive ? "destructive" : "default"}
className={cn(
action.destructive
? DRIVE_MENU_ITEM_DESTRUCTIVE_CLASS
: DRIVE_MENU_ITEM_CLASS
)}
onPointerDown={(e) => stopDriveMenuEvent(e)}
onSelect={selectAction(action.onSelect)}
>
<DriveMenuItemIcon>{action.icon}</DriveMenuItemIcon>
{action.label}
</ContextMenuItem>
))}
</>
)
}
return (
<>
{actions
.filter((action) => action.visible)
.map((action) => (
<DropdownMenuItem
key={action.key}
variant={action.destructive ? "destructive" : "default"}
className={cn(
action.destructive
? DRIVE_MENU_ITEM_DESTRUCTIVE_CLASS
: DRIVE_MENU_ITEM_CLASS
)}
onPointerDown={(e) => stopDriveMenuEvent(e)}
onSelect={() => action.onSelect()}
>
<DriveMenuItemIcon>{action.icon}</DriveMenuItemIcon>
{action.label}
</DropdownMenuItem>
))}
</>
)
}

View File

@ -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 (
<img
src={src}
alt={alt}
className="h-4 w-4 shrink-0 object-contain"
/>
)
}
const DATE_OPTIONS: { id: Exclude<DriveDatePreset, null | "custom">; 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm font-medium whitespace-nowrap transition-colors",
active
? "border-[#d2e3fc] bg-mail-active text-[#1967d2] dark:text-[#8ab4f8]"
: cn(
FILTER_BORDER,
"bg-mail-surface text-mail-text hover:bg-mail-nav-hover"
)
)}
>
{label}
<ChevronDown className="h-4 w-4 opacity-60" />
</button>
</DropdownMenuTrigger>
{children}
</DropdownMenu>
)
}
function TypeFilterDropdown() {
const types = useDriveFiltersStore((s) => s.types)
const toggleType = useDriveFiltersStore((s) => s.toggleType)
return (
<FilterChip label="Type" active={types.size > 0}>
<DropdownMenuContent
align="start"
data-drive-menu-surface
className={cn(
FILTER_DROPDOWN_CONTENT_CLASS,
"max-h-[min(70vh,420px)] w-56 overflow-y-auto"
)}
>
{TYPE_OPTIONS.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.id}
checked={types.has(opt.id)}
onCheckedChange={() => toggleType(opt.id)}
className={FILTER_CHECKBOX_ITEM_CLASS}
>
<DriveMimeCategoryIcon category={opt.id} size="sm" />
{opt.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</FilterChip>
)
}
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 (
<FilterChip label={chipLabel} active={Boolean(contactName)}>
<DropdownMenuContent align="start" data-drive-menu-surface className={cn(FILTER_DROPDOWN_CONTENT_CLASS, "w-80 p-0")}>
<div className={cn("border-b p-2", FILTER_DIVIDER)}>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher des contacts…"
className={cn("h-9 pl-8", FILTER_BORDER)}
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{query.length < 2 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
Saisissez au moins 2 caractères
</p>
) : results.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">Aucun contact</p>
) : (
results.map((c) => (
<DropdownMenuItem
key={c.uid}
className="flex flex-col items-start gap-0.5 py-2"
onClick={() => {
setContact({ name: c.full_name, email: c.email })
setQuery("")
}}
>
<span className="font-medium text-[#3c4043]">{c.full_name}</span>
{c.email ? (
<span className="text-xs text-muted-foreground">{c.email}</span>
) : null}
</DropdownMenuItem>
))
)}
</div>
{contactName ? (
<div className={cn("border-t p-2", FILTER_DIVIDER)}>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setContact(null)}
>
Effacer le contact
</Button>
</div>
) : null}
</DropdownMenuContent>
</FilterChip>
)
}
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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm font-medium whitespace-nowrap transition-colors",
datePreset
? "border-[#d2e3fc] bg-mail-active text-[#1967d2] dark:text-[#8ab4f8]"
: cn(
FILTER_BORDER,
"bg-mail-surface text-mail-text hover:bg-mail-nav-hover"
)
)}
>
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
{dateLabel}
<ChevronDown className="h-4 w-4 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" data-drive-menu-surface className={cn(FILTER_DROPDOWN_CONTENT_CLASS, "w-64 p-0")}>
<div className="py-1">
{DATE_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.id}
onSelect={() => {
setDateRange(opt.id)
setOpen(false)
}}
className={cn(datePreset === opt.id && !customMode && "bg-accent")}
>
{opt.label}
</DropdownMenuItem>
))}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setCustomMode(true)
}}
className={cn((datePreset === "custom" || customMode) && "bg-accent")}
>
Période personnalisée
</DropdownMenuItem>
</div>
{customMode ? (
<>
<div className={cn("space-y-2 border-t px-3 py-2", FILTER_DIVIDER)}>
<label className="block text-xs text-muted-foreground">
Du
<Input
type="date"
value={draftFrom}
onChange={(e) => setDraftFrom(e.target.value)}
className={cn("mt-1 h-8", FILTER_BORDER)}
/>
</label>
<label className="block text-xs text-muted-foreground">
Au
<Input
type="date"
value={draftTo}
onChange={(e) => setDraftTo(e.target.value)}
className={cn("mt-1 h-8", FILTER_BORDER)}
/>
</label>
</div>
<div className={cn("flex items-center justify-end gap-1 border-t px-2 py-2", FILTER_DIVIDER)}>
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
Annuler
</Button>
<Button
type="button"
size="sm"
disabled={!draftFrom}
onClick={() => {
setDateRange("custom", draftFrom, draftTo || null)
setOpen(false)
}}
>
Appliquer
</Button>
</div>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
function SourceFilterDropdown() {
const sources = useDriveFiltersStore((s) => s.sources)
const toggleSource = useDriveFiltersStore((s) => s.toggleSource)
return (
<FilterChip label="Source" active={sources.size > 0}>
<DropdownMenuContent align="start" data-drive-menu-surface className={cn(FILTER_DROPDOWN_CONTENT_CLASS, "w-52")}>
{SOURCE_OPTIONS.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.id}
checked={sources.has(opt.id)}
onCheckedChange={() => toggleSource(opt.id)}
className={FILTER_CHECKBOX_ITEM_CLASS}
>
<SourceAppIcon src={opt.iconSrc} alt={opt.label} />
{opt.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</FilterChip>
)
}
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 (
<div
className={cn(
"flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto py-1.5",
"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
DRIVE_CARD_PAD_X
)}
>
<TypeFilterDropdown />
{showContacts ? <ContactsFilterDropdown /> : null}
<DateFilterDropdown />
<SourceFilterDropdown />
{hasFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-[#1967d2]"
onClick={clearAll}
>
Réinitialiser les filtres
</Button>
) : null}
</div>
)
}

View File

@ -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<typeof useDriveMutations>
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 (
<DriveFileContextMenu
file={folder}
allItems={allItems}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
disableDnd={disableDnd}
mutations={mutations}
onDownloadRequest={onDownloadItem ? () => 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"
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3 text-left">
<DriveFileTypeIcon file={folder} inSharedView={inSharedView} size="md" />
<span className={FOLDER_TITLE_CLASS} title={label}>
{label}
</span>
</div>
<DriveFileMenuButton
file={folder}
allItems={allItems}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
mutations={mutations}
onDownloadRequest={onDownloadItem ? () => onDownloadItem(folder) : undefined}
onOpen={onOpen}
onActiveChange={setMenuActive}
/>
</DriveFileContextMenu>
)
}

View File

@ -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<typeof useDriveMutations>
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 (
<DriveFileContextMenu
file={file}
allItems={allItems}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
disableDnd={disableDnd}
mutations={mutations}
onDownloadRequest={onDownloadItem ? () => 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"
)}
>
<div className="flex min-w-0 items-center gap-0.5 px-2.5 pt-2">
<DriveFileTypeIcon
file={file}
inSharedView={inSharedView}
size="sm"
className="mr-1.5 shrink-0"
/>
<span className={GRID_TITLE_CLASS} title={label}>
{label}
</span>
<DriveFileMenuButton
file={file}
allItems={allItems}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
mutations={mutations}
onDownloadRequest={onDownloadItem ? () => onDownloadItem(file) : undefined}
onOpen={onOpen}
onActiveChange={setMenuActive}
/>
</div>
<div className="px-2.5 pb-2.5 pt-1">
<FileThumbnail
file={file}
variant="grid"
inSharedView={inSharedView}
publicShare={publicShare}
className="rounded-lg"
/>
</div>
</DriveFileContextMenu>
)
}

View File

@ -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<typeof useDriveMutations>
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 (
<div
data-drive-grid-root
className={cn("w-full", DRIVE_CARD_PAD_X, gridClassName)}
>
{mixedLayout ? (
<div className={DRIVE_GRID_COLS}>
{items.map((item) => (
<DriveGridCard
key={item.path}
file={item}
allItems={gridItems}
inSharedView={inSharedView}
allowShare={allowShare}
isSelected={selectedPaths.has(item.path)}
isTrash={isTrash}
{...sharedCardProps}
onOpen={() => onOpen(item)}
onItemClick={onItemClick}
/>
))}
</div>
) : (
<>
{folders.length > 0 ? (
<div className={cn(DRIVE_GRID_SECTION_GAP, DRIVE_GRID_COLS)}>
{folders.map((folder) => (
<DriveFolderGridCard
key={folder.path}
folder={folder}
allItems={gridItems}
inSharedView={inSharedView}
allowShare={allowShare}
isSelected={selectedPaths.has(folder.path)}
isTrash={isTrash}
{...sharedCardProps}
onOpen={() => onOpen(folder)}
onItemClick={onItemClick}
/>
))}
</div>
) : null}
{files.length > 0 ? (
<div className={DRIVE_GRID_COLS}>
{files.map((file) => (
<DriveGridCard
key={file.path}
file={file}
allItems={gridItems}
allowShare={allowShare}
isSelected={selectedPaths.has(file.path)}
isTrash={isTrash}
{...sharedCardProps}
onOpen={() => onOpen(file)}
onItemClick={onItemClick}
/>
))}
</div>
) : null}
</>
)}
</div>
)
}

View File

@ -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 (
<header
data-drive-header
className={cn(
"hidden h-16 shrink-0 items-center gap-2 bg-app-canvas pr-4 sm:flex",
DRIVE_MAIN_INSET_X
)}
>
{isMobile && !isXs && sidebarCollapsed ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
onClick={() => setSidebarCollapsed(false)}
aria-label="Ouvrir le menu"
>
<Menu className="h-5 w-5" />
</Button>
) : null}
<div className="flex min-w-0 flex-1 max-w-3xl overflow-visible">
<DriveSearchBar
value={search}
onChange={onSearchChange}
scope={searchScope}
onScopeChange={onSearchScopeChange}
folderPath={folderPath}
contextView={contextView}
resultsMode={resultsMode}
/>
</div>
<HeaderAccountActions
className="ml-auto shrink-0 pl-4"
settingsHref="/mail/settings"
/>
</header>
)
}

View File

@ -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 (
<span className={cn("text-sm text-muted-foreground", className)}></span>
)
}
return (
<time
dateTime={dateTime}
title={full}
aria-label={full}
className={cn("text-sm text-muted-foreground", className)}
>
{short}
</time>
)
}

View File

@ -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 (
<DriveCardRefProvider registerCardRef={registerCardRef}>
<div
data-drive-marquee-surface
className={cn("relative flex min-h-full flex-1 flex-col select-none", className)}
onPointerDown={onSurfacePointerDown}
>
{children}
{marquee ? (
<div
className="pointer-events-none fixed z-50 border border-[#1a73e8] bg-[#1a73e8]/10"
style={{
left: marquee.left,
top: marquee.top,
width: marquee.width,
height: marquee.height,
}}
/>
) : null}
</div>
</DriveCardRefProvider>
)
}

View File

@ -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 (
<>
<DriveMobileSearchSheet
open={searchOpen}
onClose={() => setSearchOpen(false)}
search={search}
onSearchChange={onSearchChange}
searchScope={searchScope}
onSearchScopeChange={onSearchScopeChange}
folderPath={folderPath}
contextView={contextView}
resultsMode={resultsMode}
onPickItem={openSuggestion}
onSubmitSearch={submitSearch}
/>
<DriveNewSheet parentPath={parentPath} open={newOpen} onOpenChange={setNewOpen} />
<div className="fixed inset-x-0 bottom-0 z-50 flex flex-col items-center pb-[env(safe-area-inset-bottom)] sm:hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-app-canvas/95 via-app-canvas/70 to-transparent" />
<div className="relative z-10 flex w-full items-center gap-2 px-3 pb-3 pt-2">
<Button
type="button"
variant="ghost"
size="icon"
className={ROUNDED_BAR_BTN}
onClick={toggleSidebar}
aria-label={sidebarOpen ? "Fermer le menu" : "Ouvrir le menu"}
>
{sidebarOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</Button>
{!sidebarOpen && (
<>
<button
type="button"
className="relative flex min-w-0 flex-1 items-center"
onClick={() => setSearchOpen(true)}
>
<div className="pointer-events-none absolute left-3 z-10 flex items-center text-gray-500">
<Search className="size-5" />
</div>
<div className="flex h-11 w-full items-center rounded-full border border-gray-200 bg-white/80 pl-10 pr-4 text-left text-sm shadow-md backdrop-blur">
<span
className={
search
? "truncate text-gray-900 dark:text-gray-100"
: "text-gray-400"
}
>
{search || "Rechercher dans Drive"}
</span>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className={ROUNDED_BAR_BTN}
onClick={() => setNewOpen(true)}
aria-label="Nouveau"
>
<Plus className="size-5" />
</Button>
</>
)}
</div>
</div>
</>
)
}

View File

@ -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 (
<Sheet open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<SheetContent
side="bottom"
hideClose
overlayClassName={DRIVE_SHEET_OVERLAY}
className={cn(DRIVE_SHEET_CONTENT, "flex h-[min(85dvh,520px)] flex-col")}
>
<SheetTitle className="sr-only">Rechercher dans Drive</SheetTitle>
<SheetDescription className="sr-only">
Rechercher des fichiers et dossiers dans Drive.
</SheetDescription>
<div className={cn("flex shrink-0 items-center gap-2 border-b px-2 py-2", DRIVE_DIALOG_DIVIDER)}>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-10 shrink-0", DRIVE_TEXT_SECONDARY)}
onClick={onClose}
aria-label="Fermer la recherche"
>
<ArrowLeft className="size-5" />
</Button>
<div className="min-w-0 flex-1">
<DriveSearchBar
value={search}
onChange={onSearchChange}
scope={searchScope}
onScopeChange={onSearchScopeChange}
folderPath={folderPath}
contextView={contextView}
resultsMode
autoFocus={open}
className="[&_input]:h-10 [&_input]:text-base"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{search.trim().length >= 2 ? (
<DriveSearchSuggestionsPanel
query={search}
scope={effectiveScope}
onScopeChange={onSearchScopeChange}
showFolderScope={showFolderScope}
suggestions={suggestions}
loading={isFetching && suggestions.length === 0}
onPickItem={(item) => {
onPickItem(item, suggestions)
onClose()
}}
onSubmitSearch={() => {
onSubmitSearch()
onClose()
}}
className="static rounded-xl shadow-none"
/>
) : (
<p className={cn("px-2 py-3 text-sm", DRIVE_TEXT_SECONDARY)}>
Recherchez des fichiers et dossiers dans tout le Drive.
</p>
)}
</div>
</SheetContent>
</Sheet>
)
}

View File

@ -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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setBrowsePath("/")
onOpenChange(next)
}}
>
<DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")}
>
<DialogHeader className={cn("border-b px-5 py-4 text-left", DRIVE_DIALOG_DIVIDER)}>
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
{titleVerb} {countLabel}
</DialogTitle>
<DialogDescription className="sr-only">
{isCopy
? `Choisir le dossier de destination pour copier ${countLabel}.`
: `Choisir le dossier de destination pour déplacer ${countLabel}.`}
</DialogDescription>
</DialogHeader>
<div className="flex min-h-[280px] flex-col">
<div
className={cn(
"flex flex-wrap items-center gap-1 border-b px-4 py-2 text-sm",
DRIVE_DIALOG_DIVIDER
)}
>
{crumbs.map((crumb, i) => (
<span key={crumb.path} className="flex min-w-0 items-center gap-1">
{i > 0 ? (
<ChevronRight className={cn("h-3.5 w-3.5 shrink-0", DRIVE_TEXT_SECONDARY)} />
) : null}
<button
type="button"
className={cn(
"truncate rounded px-1 py-0.5 hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50",
i === crumbs.length - 1
? cn("font-medium", DRIVE_TEXT_PRIMARY)
: DRIVE_TEXT_SECONDARY
)}
onClick={() => setBrowsePath(crumb.path)}
>
{crumb.label}
</button>
</span>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{list.isLoading ? (
<p className={cn("px-4 py-6 text-sm", DRIVE_TEXT_SECONDARY)}>Chargement</p>
) : folders.length === 0 ? (
<p className={cn("px-4 py-6 text-sm", DRIVE_TEXT_SECONDARY)}>Aucun sous-dossier</p>
) : (
folders.map((folder) => (
<button
key={folder.path}
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50",
DRIVE_TEXT_PRIMARY
)}
onClick={() => setBrowsePath(normalizeDriveFolderPath(folder.path))}
>
<Folder className={cn("h-4 w-4 shrink-0", DRIVE_TEXT_SECONDARY)} />
<span className="min-w-0 flex-1 truncate">{displayFileName(folder.name)}</span>
<ChevronRight className={cn("h-4 w-4 shrink-0", DRIVE_TEXT_SECONDARY)} />
</button>
))
)}
</div>
</div>
<DialogFooter className={cn(DRIVE_DIALOG_FOOTER, "px-4 py-3")}>
<Button type="button" variant="ghost" className={DRIVE_BTN_GHOST} onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
disabled={pending || blockedDestination}
onClick={() => void confirm()}
>
{pending
? isCopy
? "Copie…"
: "Déplacement…"
: isCopy
? "Copier ici"
: "Déplacer ici"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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<void>
}) {
const [value, setValue] = useState(defaultValue)
const [busy, setBusy] = useState(false)
const inputRef = useRef<HTMLInputElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={!busy}
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")}
onOpenAutoFocus={(e) => e.preventDefault()}
{...(description ? {} : { "aria-describedby": undefined })}
>
<DialogHeader className={DRIVE_DIALOG_HEADER}>
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
{title}
</DialogTitle>
{description ? (
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div className={DRIVE_DIALOG_BODY}>
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit()
}}
disabled={busy}
autoComplete="off"
className={DRIVE_FIELD_CLASS}
/>
</div>
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
<Button
type="button"
variant="ghost"
className={DRIVE_BTN_GHOST}
onClick={() => onOpenChange(false)}
disabled={busy}
>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
onClick={() => void submit()}
disabled={busy || !value.trim()}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -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 (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full cursor-pointer items-center text-left transition-colors active:bg-accent/80",
DRIVE_NEW_MENU_ITEM_CLASS,
className
)}
>
{icon}
{label}
</button>
)
}
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<typeof pickKind>[0]) => {
pickKind(kind)
closeSheet()
}
return (
<>
<DriveNameDialog
open={pendingKind !== null}
onOpenChange={(next) => {
if (!next) closeNameDialog()
}}
title={
pendingKind === "folder"
? "Nouveau dossier"
: pendingMeta
? `Nouveau ${pendingMeta.menuLabel.toLowerCase()}`
: "Nouveau"
}
defaultValue={defaultName}
confirmLabel="Créer"
onConfirm={confirmNew}
/>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="bottom"
hideClose
overlayClassName={DRIVE_SHEET_OVERLAY}
className={cn(DRIVE_SHEET_CONTENT, "max-h-[min(85dvh,520px)]")}
>
<SheetTitle className="sr-only">Nouveau</SheetTitle>
<SheetDescription className="sr-only">
Créer un document, un tableur, une présentation ou un dossier.
</SheetDescription>
<div className={cn("border-b px-4 py-3", DRIVE_DIALOG_DIVIDER)}>
<p className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>Nouveau</p>
</div>
<div className="flex flex-col p-2">
<SheetAction
icon={<FileText className="text-blue-600" />}
label="Document"
onClick={() => pick("document")}
/>
<SheetAction
icon={<FileSpreadsheet className="text-green-600" />}
label="Tableur"
onClick={() => pick("spreadsheet")}
/>
<SheetAction
icon={<Presentation className="text-amber-600" />}
label="Présentation"
onClick={() => pick("presentation")}
/>
<SheetAction
icon={<FolderPlus className="text-amber-500" />}
label="Dossier"
onClick={() => pick("folder")}
/>
<label className="flex cursor-pointer items-center active:bg-accent/80">
<span className={cn("flex w-full items-center", DRIVE_NEW_MENU_ITEM_CLASS)}>
<Upload className="text-sky-600" />
Importer un fichier
</span>
<input
type="file"
className="hidden"
multiple
onChange={async (e) => {
await uploadFiles(e.target.files)
e.target.value = ""
closeSheet()
}}
/>
</label>
<label className="flex cursor-pointer items-center active:bg-accent/80">
<span className={cn("flex w-full items-center", DRIVE_NEW_MENU_ITEM_CLASS)}>
<FolderUp className="text-violet-600" />
Importer un dossier
</span>
<input
type="file"
className="hidden"
multiple
{...({ webkitdirectory: "", directory: "" } as InputHTMLAttributes<HTMLInputElement>)}
onChange={async (e) => {
await importFolder(e.target.files)
e.target.value = ""
closeSheet()
}}
/>
</label>
</div>
</SheetContent>
</Sheet>
</>
)
}

View File

@ -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 <div aria-hidden className={cn(DRIVE_SCROLL_END_SPACER_CLASS, className)} />
}

View File

@ -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<HTMLInputElement>(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 (
<div
data-drive-search
className={cn(
"relative flex w-full min-w-0 flex-col overflow-visible",
className
)}
>
<div className={cn(DRIVE_SEARCH_INPUT_WRAP_CLASS, "text-muted-foreground")}>
<div className="pointer-events-none absolute left-3.5 flex items-center">
<Search className="size-5 shrink-0" />
</div>
<input
ref={inputRef}
type="text"
enterKeyHint="search"
placeholder="Rechercher dans Drive"
value={value}
onChange={(e) => 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 && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 text-gray-600"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange("")
inputRef.current?.focus()
}}
aria-label="Effacer la recherche"
>
<X className="h-5 w-5" />
</Button>
)}
</div>
{showPanel ? (
<DriveSearchSuggestionsPanel
query={value}
scope={effectiveScope}
onScopeChange={onScopeChange}
showFolderScope={showFolderScope}
suggestions={suggestions}
loading={isFetching && suggestions.length === 0}
onPickItem={openSuggestion}
onSubmitSearch={() => submitSearch()}
/>
) : null}
</div>
)
}

View File

@ -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 (
<nav
aria-label="Résultats de recherche"
className="flex min-w-0 flex-nowrap items-center overflow-hidden text-[#5f6368] dark:text-muted-foreground"
>
<span
className={cn(
"min-w-0 truncate text-base font-normal tracking-tight text-[#202124] md:text-[1.375rem] dark:text-foreground",
CRUMB_LINE_CLASS
)}
title={searchResultsTitle(search)}
>
{searchResultsTitle(search)}
</span>
</nav>
)
}

View File

@ -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 (
<div className="flex flex-wrap gap-1.5 px-3 pb-2 pt-1">
{options.map((option) => (
<button
key={option}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onScopeChange(option)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
scope === option
? "bg-mail-active text-[#1967d2] dark:text-[#8ab4f8]"
: "bg-mail-surface-muted text-mail-text-muted hover:bg-mail-nav-hover"
)}
>
{driveSearchScopeShortLabel(option)}
</button>
))}
</div>
)
}
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 (
<div className="group flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(item)}
>
<DriveFileTypeIcon file={item} className="size-5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-[#202124] dark:text-foreground">
{displayFileName(item.name)}
</span>
<span className="block truncate text-xs text-[#5f6368] dark:text-muted-foreground">
{location}
</span>
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-[#5f6368] opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 dark:text-muted-foreground"
aria-label="Ouvrir le dossier parent"
onMouseDown={(e) => e.preventDefault()}
onClick={() => router.push(parentHref)}
>
<FolderOpen className="size-4" />
</Button>
</div>
)
}
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 (
<div
data-drive-search-suggestions
className={cn(
MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS,
"top-[calc(100%+6px)] rounded-2xl",
className
)}
>
<ScopePicker
scope={scope}
onScopeChange={onScopeChange}
showFolderScope={showFolderScope}
/>
<div className="border-t border-mail-border-subtle px-1 py-1">
{loading ? (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-[#5f6368] dark:text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Recherche
</div>
) : suggestions.length === 0 ? (
<p className="px-3 py-4 text-sm text-[#5f6368] dark:text-muted-foreground">
Aucune suggestion pour « {trimmed} »
</p>
) : (
<>
{suggestions.map((item) => (
<SuggestionRow
key={item.path}
item={item}
scope={scope}
onPick={onPickItem}
/>
))}
<button
type="button"
className="mt-0.5 w-full rounded-lg px-3 py-2 text-left text-sm font-medium text-[#1967d2] hover:bg-mail-nav-hover dark:text-[#8ab4f8]"
onMouseDown={(e) => e.preventDefault()}
onClick={onSubmitSearch}
>
Tous les résultats pour « {trimmed} »
</button>
</>
)}
</div>
</div>
)
}

View File

@ -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 (
<aside
className={cn(
"flex h-full w-56 shrink-0 flex-col bg-app-canvas text-foreground",
overlay
? cn(
"fixed inset-y-0 left-0 z-50 shadow-xl transition-transform duration-200 ease-linear",
open ? "translate-x-0" : "-translate-x-full pointer-events-none"
)
: "relative"
)}
aria-hidden={overlay && !open}
>
<div className="flex shrink-0 items-center justify-between gap-2 px-4 py-4">
<div className="flex min-w-0 items-center gap-2">
<img
src="/drive/ultidrive-mark.svg"
alt=""
className="h-8 w-8 shrink-0"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
<span className="truncate text-lg font-medium">UltiDrive</span>
</div>
{isXs ? (
<Button
variant="ghost"
size="icon"
className="size-9 shrink-0 rounded-full text-gray-600 dark:text-muted-foreground"
aria-label="Réglages"
asChild
>
<Link href="/mail/settings">
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Link>
</Button>
) : null}
</div>
<div className="flex shrink-0 px-3 pb-3">
<DriveNewMenu parentPath={parentPath} />
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-2">
<div className="pb-1">
<DriveSidebarFolderTree
view="files"
pathSegments={filesSegments}
active={route.view === "files"}
/>
<DriveSidebarFolderTree
view="shared"
pathSegments={sharedSegments}
active={route.view === "shared"}
/>
</div>
{OTHER_NAV.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href)
return (
<Link
key={href}
href={href}
onClick={() => {
if (overlay) closeSidebar()
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm",
mailNavRowClass({ isSelected: active })
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
)
})}
</nav>
<div
className={cn(
"sticky bottom-0 shrink-0 border-t border-border bg-app-canvas",
isXs && "pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
<div className={cn(isXs ? "px-3 pt-1.5 pb-0" : "p-3")}>
<DriveQuotaBar />
</div>
{isXs ? (
<>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2.5 px-4 py-0.5 text-left transition-colors hover:bg-mail-nav-hover"
aria-label={`Compte : ${identity?.email ?? displayName}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => setAccountMenuOpen(true)}
>
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="sm"
/>
) : (
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
?
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-muted-foreground">
Connecté en tant que
</span>
<span className="block truncate text-sm font-medium">
{displayName}
</span>
</span>
</button>
<AccountSwitcherSheet
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
/>
</>
) : null}
</div>
</aside>
)
}

View File

@ -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 (
<p className="px-4 pt-3 pb-1 text-xs font-medium text-[#5f6368] dark:text-muted-foreground">
{children}
</p>
)
}
function SortMenuOption({
label,
selected,
onSelect,
}: {
label: string
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
role="menuitemradio"
aria-checked={selected}
onClick={onSelect}
className={cn(
"flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left text-sm text-[#202124] transition-colors dark:text-foreground",
selected ? "bg-[#e8f0fe] dark:bg-primary/15" : "hover:bg-[#f1f3f4] dark:hover:bg-accent/60"
)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{selected ? <Check className="h-4 w-4 text-[#1967d2]" strokeWidth={2.5} /> : null}
</span>
{label}
</button>
)
}
function SortMenuDivider() {
return <div className="my-1 border-t border-[#e8eaed] dark:border-border" />
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1 rounded-full px-3 text-sm font-medium transition-colors",
"bg-[#e8f0fe] text-[#1967d2] hover:bg-[#d2e3fc]",
"dark:bg-primary/15 dark:text-primary dark:hover:bg-primary/25"
)}
>
{triggerLabel}
{sortDir === "asc" ? (
<ArrowUp className="h-4 w-4 shrink-0 opacity-80" />
) : (
<ArrowDown className="h-4 w-4 shrink-0 opacity-80" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6} data-drive-menu-surface className={MENU_SURFACE}>
<SortSectionTitle>Trier par</SortSectionTitle>
{SORT_FIELDS.map((field) => (
<SortMenuOption
key={field}
label={DRIVE_SORT_FIELD_LABELS[field]}
selected={sortField === field}
onSelect={() => setSortField(field)}
/>
))}
<SortMenuDivider />
<SortSectionTitle>Ordre de tri</SortSectionTitle>
<SortMenuOption
label={orderLabels.asc}
selected={sortDir === "asc"}
onSelect={() => setSortDir("asc")}
/>
<SortMenuOption
label={orderLabels.desc}
selected={sortDir === "desc"}
onSelect={() => setSortDir("desc")}
/>
<SortMenuDivider />
<SortSectionTitle>Dossiers</SortSectionTitle>
<SortMenuOption
label="En haut"
selected={folderPlacement === "top"}
onSelect={() => setFolderPlacement("top")}
/>
<SortMenuOption
label="Mélangés avec les fichiers"
selected={folderPlacement === "mixed"}
onSelect={() => setFolderPlacement("mixed")}
/>
<div className="h-2" aria-hidden />
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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 (
<div
className="flex shrink-0 items-center rounded-full border border-mail-border-subtle bg-mail-surface-muted p-0.5"
role="group"
aria-label="Mode daffichage"
>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-full",
viewMode === "list" && "bg-mail-surface shadow-sm"
)}
onClick={() => setViewMode("list")}
aria-pressed={viewMode === "list"}
>
<List className="h-4 w-4 text-[#5f6368]" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-full",
viewMode === "grid" && "bg-mail-surface shadow-sm"
)}
onClick={() => setViewMode("grid")}
aria-pressed={viewMode === "grid"}
>
<Grid3X3 className="h-4 w-4 text-[#5f6368]" />
</Button>
</div>
)
}

View File

@ -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<typeof useDriveMutations>
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 (
<DriveGridView
items={items}
isTrash={isTrash}
inSharedView={view === "shared"}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
disableDnd={disableDnd}
mutations={mutations}
onDownloadItem={onDownloadItem}
gridClassName={gridClassName}
publicShare={publicShare}
onOpen={openItem}
onItemClick={handleItemClick}
/>
)
}
return (
<div>
<div
className={cn(
"sticky top-0 z-10 hidden items-center gap-3 border-b border-border bg-mail-surface py-2 text-xs font-medium text-muted-foreground sm:flex",
DRIVE_CARD_PAD_X
)}
aria-hidden
>
<span className="size-10 shrink-0" />
<span className="min-w-0 flex-1">Nom</span>
<span className="hidden w-16 shrink-0 text-right sm:block">Type</span>
<span className="hidden w-20 shrink-0 text-right sm:block">Taille</span>
<span className="hidden w-36 shrink-0 text-right md:block">Modifié</span>
</div>
<div className="divide-y divide-border">
{items.map((file) => (
<DriveFileContextMenu
key={file.path}
file={file}
allItems={items}
isTrash={isTrash}
allowShare={allowShare}
writable={writable}
hideFavorite={hideFavorite}
disableDnd={disableDnd}
mutations={mutations}
onDownloadRequest={
onDownloadItem ? () => onDownloadItem(file) : undefined
}
onOpen={() => openItem(file)}
onItemClick={handleItemClick}
>
<div
className={cn(
"flex w-full cursor-pointer items-center gap-3 py-2.5 text-left hover:bg-accent/50",
DRIVE_CARD_PAD_X
)}
>
<FileThumbnail
file={file}
variant="list"
inSharedView={view === "shared"}
publicShare={publicShare}
/>
<span className="min-w-0 flex-1 truncate font-medium text-[#3c4043] dark:text-[#e8eaed]">
{displayFileBaseName(file.name, file.type === "directory")}
</span>
<span className="hidden w-16 shrink-0 truncate text-right text-sm text-muted-foreground sm:block">
{displayFileFormatLabel(file.name, file.type === "directory")}
</span>
<span className="hidden w-20 shrink-0 text-right text-sm text-muted-foreground sm:block">
{file.type === "directory" ? "—" : formatSize(file.size)}
</span>
<DriveListModified
iso={file.last_modified}
className="hidden w-36 shrink-0 truncate text-right md:block"
/>
</div>
</DriveFileContextMenu>
))}
</div>
</div>
)
}

View File

@ -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: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Ouverture du PDF</span>
</div>
),
},
)
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 (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(PREVIEW_ACTION_BTN, className)}
onClick={onClick}
disabled={disabled}
aria-label={label}
>
{children}
</Button>
)
}
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 (
<pre className="h-full w-full overflow-auto rounded-md bg-zinc-900 p-4 text-left font-mono text-sm leading-relaxed whitespace-pre-wrap break-words text-zinc-100">
{textContent ?? ""}
</pre>
)
}
if (svgMarkup) {
return <SvgPreviewViewer markup={svgMarkup} name={name} />
}
if (kind === "image") {
return (
<img
src={blobUrl}
alt={displayFileName(name)}
className="max-h-full max-w-full object-contain"
onError={onImageError}
/>
)
}
if (kind === "video") {
return (
<video
src={blobUrl}
controls
autoPlay
className="max-h-full max-w-full rounded-md bg-black"
playsInline
>
<track kind="captions" />
</video>
)
}
if (kind === "audio") {
return (
<audio src={blobUrl} controls autoPlay className="w-full max-w-lg">
<track kind="captions" />
</audio>
)
}
return <PdfPreviewViewer key={blobUrl} blobUrl={blobUrl} name={name} />
}
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<DriveFolderPickerMode | null>(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<string | null>(null)
const [textContent, setTextContent] = useState<string | null>(null)
const [svgMarkup, setSvgMarkup] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const blobUrlRef = useRef<string | null>(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 laperç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 laperç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 (
<>
<MailDriveFolderPicker
open={mailSavePickerOpen}
onOpenChange={setMailSavePickerOpen}
title="Enregistrer dans UltiDrive"
description="Choisissez un dossier dans votre Drive."
confirmLabel="Enregistrer ici"
pending={saveToDrive.isPending}
onConfirm={onMailSaveToDrive}
/>
<DriveMoveDialog
open={folderPickerMode !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setFolderPickerMode(null)
}}
mode={folderPickerMode ?? "move"}
sources={fileInfo ? [fileInfo] : []}
onMoved={() => {
if (folderPickerMode === "move" && file) removePreviewFile(file.path)
setFolderPickerMode(null)
}}
/>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) closePreview()
}}
>
<DialogContent
aria-describedby={undefined}
showCloseButton={false}
overlayClassName="bg-black/90"
className={cn(
"flex h-[min(92dvh,920px)] w-[min(96vw,1280px)] max-w-none flex-col gap-0 overflow-hidden",
"border-0 bg-zinc-950 p-0 text-zinc-100 shadow-2xl sm:max-w-none"
)}
>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-white/10 px-4">
<DialogTitle className="min-w-0 flex-1 truncate text-left text-base font-medium text-zinc-100">
{title}
</DialogTitle>
{positionLabel ? (
<span className="shrink-0 text-sm tabular-nums text-zinc-400">{positionLabel}</span>
) : null}
{file ? (
<div className="flex shrink-0 items-center gap-0.5">
{showWriteActions && allowShare ? (
<PreviewActionButton label="Partager" onClick={onShare}>
<UserPlus className="h-5 w-5" />
</PreviewActionButton>
) : null}
{showWriteActions ? (
<PreviewActionButton
label={favoriteLabel}
onClick={() => void onToggleFavorite()}
disabled={mutations.favorite.isPending}
className={cn(
file.is_favorite && "text-amber-400 hover:bg-amber-400/10 hover:text-amber-300"
)}
>
<Star
className="h-5 w-5"
fill={file.is_favorite ? "currentColor" : "none"}
/>
</PreviewActionButton>
) : null}
{showWriteActions ? (
<>
<PreviewActionButton
label="Déplacer vers"
onClick={() => setFolderPickerMode("move")}
>
<FolderInput className="h-5 w-5" />
</PreviewActionButton>
<PreviewActionButton
label="Copier vers"
onClick={() => setFolderPickerMode("copy")}
className="max-sm:hidden"
>
<Copy className="h-5 w-5" />
</PreviewActionButton>
</>
) : null}
{isMailAttachment && !mailDrivePath ? (
<PreviewActionButton
label="Enregistrer dans UltiDrive"
onClick={() => setMailSavePickerOpen(true)}
disabled={saveToDrive.isPending}
>
<HardDrive className="h-5 w-5" />
</PreviewActionButton>
) : null}
{isMailAttachment && mailDrivePath ? (
<PreviewActionButton
label={`Emplacement : ${mailDriveFolderLabel(mailDrivePath)}`}
onClick={() => {}}
disabled
className="!w-auto max-w-[min(40vw,16rem)] px-2 opacity-100"
>
<Link
href={mailDriveFileHref(mailDrivePath)}
className="inline-flex min-w-0 items-center gap-1.5 text-zinc-300 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-4 w-4 shrink-0" />
<span className="truncate text-xs font-normal">
{mailDriveFolderLabel(mailDrivePath)}
</span>
</Link>
</PreviewActionButton>
) : null}
<PreviewActionButton
label="Télécharger"
onClick={() =>
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))
}
>
<Download className="h-5 w-5" />
</PreviewActionButton>
{showWriteActions ? (
<PreviewActionButton
label="Supprimer"
onClick={() => void onDelete()}
disabled={mutations.deleteFile.isPending}
className="hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="h-5 w-5" />
</PreviewActionButton>
) : null}
<DialogClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={PREVIEW_ACTION_BTN}
aria-label="Fermer"
>
<X className="h-5 w-5" />
</Button>
</DialogClose>
</div>
) : null}
</div>
<div className="relative flex min-h-0 flex-1 overflow-hidden">
{hasPrev ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 h-10 w-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={() => stepPreview(-1)}
aria-label="Fichier précédent"
>
<ChevronLeft className="h-6 w-6" />
</Button>
) : null}
{hasNext ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 h-10 w-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={() => stepPreview(1)}
aria-label="Fichier suivant"
>
<ChevronRight className="h-6 w-6" />
</Button>
) : null}
<div
className={cn(
"flex min-h-0 flex-1 overflow-hidden",
kind === "pdf" ? "flex-col p-0" : kind === "text" ? "flex-col p-4" : kind === "audio" ? "flex-col items-center justify-center p-8" : "items-center justify-center p-4"
)}
>
{loading ? (
<div className="flex flex-col items-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Chargement</span>
</div>
) : null}
{!loading && error ? (
<p className="text-center text-sm text-zinc-400">{error}</p>
) : null}
{!loading && !error && imgFailed ? (
<p className="text-center text-sm text-zinc-400">
Aperçu non pris en charge par le navigateur (ex. HEIC). Téléchargez le fichier.
</p>
) : null}
{!loading && !error && !imgFailed && kind && file && previewReady ? (
<PreviewBody
kind={kind}
blobUrl={blobUrl ?? ""}
name={file.name}
textContent={textContent}
svgMarkup={svgMarkup}
onImageError={() => setImgFailed(true)}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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<HTMLDivElement>(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 (
<>
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onLoadedData={(e) => {
const v = e.currentTarget
if (v.currentTime === 0) v.currentTime = 0.1
}}
onError={onError}
/>
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20"
aria-hidden
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 shadow-md">
<Play className="ml-0.5 h-4 w-4 fill-white text-white" />
</div>
</div>
</>
)
}
return (
<img
src={url}
alt=""
className={cn(
"h-full w-full object-cover",
darkInvert && "dark:invert dark:hue-rotate-180",
)}
draggable={false}
onError={onError}
/>
)
}
export function FileThumbnail({
file,
variant = "grid",
inSharedView,
publicShare,
className,
}: {
file: DriveFileInfo
variant?: "list" | "grid"
inSharedView?: boolean
publicShare?: PublicShareThumbContext
className?: string
}) {
const { ref, inView } = useInView()
const [failed, setFailed] = useState(false)
const retriedRef = useRef(false)
const queryClient = useQueryClient()
const previewKind = drivePreviewKind(file)
const canPreview =
file.type === "file" &&
previewKind !== "audio" &&
(driveServerThumbnail(file) || previewKind !== null)
const showPreview = canPreview && !failed
const authThumb = useDrivePreviewThumb(file, !publicShare && inView && showPreview)
const publicThumb = usePublicSharePreviewThumb(file, publicShare, inView && showPreview)
const { data, isLoading } = publicShare ? publicThumb : authThumb
const showIcon = !showPreview || failed || (!data && !isLoading)
const darkInvertThumb = isOfficeFormat(file)
const iconSize = variant === "list" ? "md" : "lg"
const sizeClass =
variant === "list" ? "h-10 w-10 rounded-lg" : "aspect-[4/3] w-full rounded-lg"
useEffect(() => {
retriedRef.current = false
setFailed(false)
}, [file.path, file.etag])
const handlePreviewError = () => {
if (!retriedRef.current && data?.url.startsWith("blob:")) {
retriedRef.current = true
void queryClient.invalidateQueries({
queryKey: publicShare
? ["public-share", "preview-thumb", publicShare.token, file.path, file.etag]
: ["drive", "preview-thumb", file.path, file.etag],
})
return
}
setFailed(true)
}
return (
<div
ref={ref}
className={cn(
"relative shrink-0 overflow-hidden bg-[#f8f9fa] dark:bg-muted/30",
sizeClass,
className
)}
>
{showIcon ? (
<div className="flex h-full w-full items-center justify-center">
<DriveFileTypeIcon
file={file}
inSharedView={inSharedView}
size={iconSize}
className={isLoading && showPreview ? "opacity-40" : undefined}
/>
</div>
) : null}
{showPreview && isLoading ? (
<div className="absolute inset-0 animate-pulse bg-muted" aria-hidden />
) : null}
{showPreview && data ? (
<div className="absolute inset-0 overflow-hidden bg-white dark:bg-transparent">
<PreviewContent
url={data.url}
display={data.display}
darkInvert={darkInvertThumb}
onError={handlePreviewError}
/>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,136 @@
"use client"
import type { InputHTMLAttributes } from "react"
import {
FileSpreadsheet,
FileText,
FolderPlus,
FolderUp,
Plus,
Presentation,
Upload,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DriveNameDialog } from "@/components/drive/drive-name-dialog"
import {
DRIVE_NEW_MENU_ITEM_CLASS,
useDriveNewMenu,
} from "@/lib/hooks/use-drive-new-menu"
import { cn } from "@/lib/utils"
export function DriveNewMenu({ parentPath }: { parentPath: string }) {
const {
pendingKind,
pendingMeta,
defaultName,
confirmNew,
uploadFiles,
importFolder,
pickKind,
closeNameDialog,
} = useDriveNewMenu(parentPath)
return (
<>
<DriveNameDialog
open={pendingKind !== null}
onOpenChange={(open) => {
if (!open) closeNameDialog()
}}
title={
pendingKind === "folder"
? "Nouveau dossier"
: pendingMeta
? `Nouveau ${pendingMeta.menuLabel.toLowerCase()}`
: "Nouveau"
}
defaultValue={defaultName}
confirmLabel="Créer"
onConfirm={confirmNew}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-12 w-auto min-w-0 cursor-pointer items-center justify-start gap-3 rounded-2xl border border-border",
"bg-mail-surface px-5 text-[15px] font-medium text-foreground shadow-sm outline-none transition-[box-shadow,background-color]",
"hover:bg-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Plus className="size-5 shrink-0 opacity-80" />
Nouveau
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={8}
data-drive-menu-surface
className="w-64 border-border bg-mail-surface-elevated p-1.5 shadow-lg"
>
<DropdownMenuItem
className={DRIVE_NEW_MENU_ITEM_CLASS}
onClick={() => pickKind("document")}
>
<FileText className="text-blue-600" />
Document
</DropdownMenuItem>
<DropdownMenuItem
className={DRIVE_NEW_MENU_ITEM_CLASS}
onClick={() => pickKind("spreadsheet")}
>
<FileSpreadsheet className="text-green-600" />
Tableur
</DropdownMenuItem>
<DropdownMenuItem
className={DRIVE_NEW_MENU_ITEM_CLASS}
onClick={() => pickKind("presentation")}
>
<Presentation className="text-amber-600" />
Présentation
</DropdownMenuItem>
<DropdownMenuItem className={DRIVE_NEW_MENU_ITEM_CLASS} onClick={() => pickKind("folder")}>
<FolderPlus className="text-amber-500" />
Dossier
</DropdownMenuItem>
<DropdownMenuItem className={DRIVE_NEW_MENU_ITEM_CLASS} asChild>
<label className="flex cursor-pointer items-center">
<Upload className="text-sky-600" />
Importer un fichier
<input
type="file"
className="hidden"
multiple
onChange={async (e) => {
await uploadFiles(e.target.files)
e.target.value = ""
}}
/>
</label>
</DropdownMenuItem>
<DropdownMenuItem className={DRIVE_NEW_MENU_ITEM_CLASS} asChild>
<label className="flex cursor-pointer items-center">
<FolderUp className="text-violet-600" />
Importer un dossier
<input
type="file"
className="hidden"
multiple
{...({ webkitdirectory: "", directory: "" } as InputHTMLAttributes<HTMLInputElement>)}
onChange={async (e) => {
await importFolder(e.target.files)
e.target.value = ""
}}
/>
</label>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@ -0,0 +1,215 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { apiClient } from "@/lib/api/client"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
type DocEditorInstance = { destroyEditor: () => void }
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
}
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
}
}
let docsApiLoad: Promise<void> | null = null
function loadDocsApi(documentServerUrl: string): Promise<void> {
if (window.DocsAPI) return Promise.resolve()
if (docsApiLoad) return docsApiLoad
const base = documentServerUrl.replace(/\/$/, "") + "/"
docsApiLoad = new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = "onlyoffice-docs-api"
script.src = `${base}web-apps/apps/api/documents/api.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => {
docsApiLoad = null
reject(new Error(`Error load DocsAPI from ${base}`))
}
document.body.appendChild(script)
})
return docsApiLoad
}
function destroyDocEditor(id: string) {
const inst = window.DocEditor?.instances?.[id]
if (inst) {
try {
inst.destroyEditor()
} catch {
/* ignore */
}
delete window.DocEditor!.instances[id]
}
document.getElementById(id)?.replaceChildren()
}
function OnlyOfficeMount({
editorId,
documentServerUrl,
config,
onError,
}: {
editorId: string
documentServerUrl: string
config: Record<string, unknown>
onError: (message: string) => void
}) {
const configJson = JSON.stringify(config)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
useEffect(() => {
let cancelled = false
const id = editorId
const parsed = JSON.parse(configJson) as Record<string, unknown>
const editorConfig: Record<string, unknown> = {
type: "desktop",
width: "100%",
height: "100%",
events: {
onDocumentReady: () => {
/* loaded */
},
onError: (event: { data?: { errorCode?: number; errorDescription?: string } }) => {
const code = event?.data?.errorCode
const desc = event?.data?.errorDescription
const msg =
desc ||
(code != null ? `OnlyOffice error ${code}` : "Erreur OnlyOffice.")
onErrorRef.current(msg)
},
},
...parsed,
}
void loadDocsApi(documentServerUrl)
.then(() => {
if (cancelled) return
if (!window.DocsAPI) throw new Error("DocsAPI is not defined")
destroyDocEditor(id)
if (!window.DocEditor) window.DocEditor = { instances: {} }
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
window.DocEditor.instances[id] = editor
})
.catch((err: unknown) => {
if (!cancelled) {
onErrorRef.current(
err instanceof Error ? err.message : "Impossible de charger OnlyOffice.",
)
}
})
return () => {
cancelled = true
destroyDocEditor(id)
}
}, [editorId, documentServerUrl, configJson])
return <div id={editorId} className="h-full w-full min-h-0" />
}
export function OfficeEditor({
filePath,
returnTo,
}: {
filePath: string
returnTo?: string | null
}) {
const instanceSeq = useRef(0)
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const backHref = useMemo(
() =>
resolveDriveEditReturnTo(returnTo, filePath, (folderPath) =>
driveFolderHref("files", folderPath)
),
[returnTo, filePath]
)
const handleEditorError = useCallback((message: string) => {
setError(message)
}, [])
useEffect(() => {
let cancelled = false
setConfig(null)
setServerUrl("")
setEditorId(null)
setError(null)
void (async () => {
try {
const res = await apiClient.post<{
config: Record<string, unknown>
serverUrl: string
}>("/office/session", { path: filePath, mode: "edit" })
if (cancelled) return
instanceSeq.current += 1
setConfig(res.config)
setServerUrl(res.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setEditorId(`ultidrive-editor-${instanceSeq.current}`)
} catch {
if (!cancelled) setError("Impossible de charger léditeur.")
}
})()
return () => {
cancelled = true
}
}, [filePath])
if (error) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-4">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
</div>
)
}
if (!config || !editorId || !serverUrl) {
return <p className="p-8 text-center text-muted-foreground">Ouverture du document</p>
}
const docServer = serverUrl.replace(/\/$/, "")
return (
<div className="flex h-dvh flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
Drive
</Link>
</Button>
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
</div>
<div className="relative min-h-0 flex-1">
<OnlyOfficeMount
editorId={editorId}
documentServerUrl={docServer}
config={config}
onError={handleEditorError}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,415 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronDown, ChevronUp, Loader2, Printer, ZoomIn, ZoomOut } from "lucide-react"
import {
getDocument,
GlobalWorkerOptions,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
} from "pdfjs-dist"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
const PAGE_GAP_PX = 24
const MIN_ZOOM = 0.5
const MAX_ZOOM = 2.5
const ZOOM_STEP = 0.15
const RENDER_MARGIN_PX = 320
const HORIZONTAL_PADDING_PX = 48
function fitScaleForPage(pageWidth: number, containerWidth: number): number {
if (pageWidth <= 0 || containerWidth <= 0) return 1
return Math.min(2, Math.max(0.25, containerWidth / pageWidth))
}
async function releasePdfDocument(doc: PDFDocumentProxy | null) {
if (!doc) return
try {
await doc.cleanup()
} catch {
/* already released */
}
}
async function releaseLoadingTask(task: PDFDocumentLoadingTask | null) {
if (!task || task.destroyed) return
try {
await task.destroy()
} catch {
/* already released */
}
}
type PdfPreviewViewerProps = {
blobUrl: string
name: string
}
function PdfPageCanvas({
pdf,
pageNumber,
containerWidth,
zoom,
shouldRender,
}: {
pdf: PDFDocumentProxy
pageNumber: number
containerWidth: number
zoom: number
shouldRender: boolean
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const renderTaskRef = useRef<{ cancel: () => void } | null>(null)
useEffect(() => {
if (!shouldRender || containerWidth <= 0 || zoom <= 0) return
let cancelled = false
const canvas = canvasRef.current
if (!canvas) return
;(async () => {
try {
const page = await pdf.getPage(pageNumber)
if (cancelled) return
const base = page.getViewport({ scale: 1 })
const scale = fitScaleForPage(base.width, containerWidth) * zoom
const viewport = page.getViewport({ scale })
const ctx = canvas.getContext("2d")
if (!ctx) return
const outputScale = window.devicePixelRatio || 1
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
ctx.setTransform(outputScale, 0, 0, outputScale, 0, 0)
renderTaskRef.current?.cancel()
const task = page.render({ canvasContext: ctx, viewport, canvas })
renderTaskRef.current = task
await task.promise
} catch {
/* render cancelled or viewer unmounted */
}
})()
return () => {
cancelled = true
renderTaskRef.current?.cancel()
renderTaskRef.current = null
}
}, [pdf, pageNumber, containerWidth, zoom, shouldRender])
return (
<canvas
ref={canvasRef}
className="block bg-white"
aria-hidden
/>
)
}
function printPdfBlob(blobUrl: string, title: string) {
const frame = document.createElement("iframe")
frame.style.position = "fixed"
frame.style.right = "0"
frame.style.bottom = "0"
frame.style.width = "0"
frame.style.height = "0"
frame.style.border = "0"
frame.setAttribute("title", title)
frame.src = blobUrl
const cleanup = () => {
frame.remove()
}
frame.onload = () => {
try {
frame.contentWindow?.focus()
frame.contentWindow?.print()
} finally {
window.setTimeout(cleanup, 1000)
}
}
document.body.appendChild(frame)
}
export function PdfPreviewViewer({ blobUrl, name }: PdfPreviewViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const loadingTaskRef = useRef<PDFDocumentLoadingTask | null>(null)
const [pdf, setPdf] = useState<PDFDocumentProxy | null>(null)
const [numPages, setNumPages] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [containerWidth, setContainerWidth] = useState(0)
const [zoom, setZoom] = useState(1)
const [visiblePages, setVisiblePages] = useState<Set<number>>(() => new Set([1]))
useEffect(() => {
let destroyed = false
let doc: PDFDocumentProxy | null = null
loadingTaskRef.current = null
setLoading(true)
setError(null)
setPdf(null)
setNumPages(0)
setCurrentPage(1)
setZoom(1)
setVisiblePages(new Set([1]))
pageRefs.current.clear()
;(async () => {
try {
const response = await fetch(blobUrl)
if (!response.ok) throw new Error("fetch failed")
const data = await response.arrayBuffer()
if (destroyed) return
const task = getDocument({ data, isEvalSupported: false })
loadingTaskRef.current = task
const loaded = await task.promise
if (destroyed) {
await releasePdfDocument(loaded)
return
}
doc = loaded
setPdf(loaded)
setNumPages(loaded.numPages)
setLoading(false)
} catch {
if (!destroyed) {
setError("Impossible dafficher ce PDF.")
setLoading(false)
}
}
})()
return () => {
destroyed = true
void releaseLoadingTask(loadingTaskRef.current)
void releasePdfDocument(doc)
}
}, [blobUrl])
const updateContainerWidth = useCallback(() => {
const el = scrollRef.current
if (!el) return
const width = el.clientWidth - HORIZONTAL_PADDING_PX
if (width > 0) setContainerWidth(width)
}, [])
useEffect(() => {
if (!pdf) return
updateContainerWidth()
const el = scrollRef.current
if (!el) return
const ro = new ResizeObserver(updateContainerWidth)
ro.observe(el)
return () => ro.disconnect()
}, [pdf, updateContainerWidth])
const updateCurrentPageFromScroll = useCallback(() => {
const root = scrollRef.current
if (!root || pageRefs.current.size === 0) return
const focusY = root.getBoundingClientRect().top + root.clientHeight * 0.35
let bestPage = 1
let bestDist = Number.POSITIVE_INFINITY
pageRefs.current.forEach((node, page) => {
const dist = Math.abs(node.getBoundingClientRect().top - focusY)
if (dist < bestDist) {
bestDist = dist
bestPage = page
}
})
setCurrentPage(bestPage)
}, [])
useEffect(() => {
if (!pdf || numPages === 0) return
const root = scrollRef.current
if (!root) return
const observer = new IntersectionObserver(
(entries) => {
setVisiblePages((prev) => {
const merged = new Set(prev)
for (const entry of entries) {
const page = Number((entry.target as HTMLElement).dataset.page)
if (!page) continue
if (entry.isIntersecting) merged.add(page)
}
return merged
})
updateCurrentPageFromScroll()
},
{ root, rootMargin: `${RENDER_MARGIN_PX}px 0px`, threshold: 0 },
)
const nodes = [...pageRefs.current.values()]
nodes.forEach((node) => observer.observe(node))
root.addEventListener("scroll", updateCurrentPageFromScroll, { passive: true })
updateCurrentPageFromScroll()
return () => {
observer.disconnect()
root.removeEventListener("scroll", updateCurrentPageFromScroll)
}
}, [pdf, numPages, updateCurrentPageFromScroll])
const scrollToPage = (page: number) => {
const node = pageRefs.current.get(page)
node?.scrollIntoView({ behavior: "smooth", block: "start" })
setCurrentPage(page)
}
const setPageRef = (page: number) => (node: HTMLDivElement | null) => {
if (node) pageRefs.current.set(page, node)
else pageRefs.current.delete(page)
}
if (loading) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<span className="text-sm">Ouverture du PDF</span>
</div>
)
}
if (error || !pdf) {
return <p className="text-center text-sm text-zinc-400">{error ?? "Aperçu indisponible."}</p>
}
const zoomPercent = Math.round(zoom * 100)
return (
<div className="flex h-full min-h-0 flex-col">
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-auto overscroll-contain bg-zinc-900/60"
aria-label={`Aperçu PDF : ${name}`}
>
<div
className={cn(
"mx-auto flex w-max min-w-full flex-col items-center px-4 py-6",
zoom <= 1 && "max-w-5xl",
)}
style={{ gap: PAGE_GAP_PX }}
>
{Array.from({ length: numPages }, (_, i) => {
const pageNumber = i + 1
return (
<div
key={pageNumber}
ref={setPageRef(pageNumber)}
data-page={pageNumber}
className={cn(
"inline-flex w-max max-w-none flex-col scroll-mt-4",
"rounded-sm bg-white shadow-[0_8px_32px_rgba(0,0,0,0.45)] ring-1 ring-white/10",
)}
>
<div className="flex w-full shrink-0 items-center justify-between border-b border-zinc-200/80 bg-zinc-50 px-3 py-1.5">
<span className="text-xs font-medium tabular-nums text-zinc-500">
Page {pageNumber}
</span>
</div>
<div className="flex shrink-0 justify-center bg-zinc-100 p-2 sm:p-3">
<PdfPageCanvas
pdf={pdf}
pageNumber={pageNumber}
containerWidth={containerWidth}
zoom={zoom}
shouldRender={visiblePages.has(pageNumber) && containerWidth > 0}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-white/10 bg-zinc-950/95 px-4 py-2.5 backdrop-blur-sm">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => printPdfBlob(blobUrl, name)}
aria-label="Imprimer"
>
<Printer className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
disabled={zoom <= MIN_ZOOM}
aria-label="Zoom arrière"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[3.5rem] text-center text-xs tabular-nums text-zinc-400">
{zoomPercent}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
disabled={zoom >= MAX_ZOOM}
aria-label="Zoom avant"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<p className="text-sm font-medium tabular-nums text-zinc-200">
Page {currentPage} / {numPages}
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => scrollToPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
aria-label="Page précédente"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => scrollToPage(Math.min(numPages, currentPage + 1))}
disabled={currentPage >= numPages}
aria-label="Page suivante"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,225 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
type DocEditorInstance = { destroyEditor: () => void }
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
}
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
}
}
let docsApiLoad: Promise<void> | null = null
function loadDocsApi(documentServerUrl: string): Promise<void> {
if (window.DocsAPI) return Promise.resolve()
if (docsApiLoad) return docsApiLoad
const base = documentServerUrl.replace(/\/$/, "") + "/"
docsApiLoad = new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = "onlyoffice-docs-api-public"
script.src = `${base}web-apps/apps/api/documents/api.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => {
docsApiLoad = null
reject(new Error(`Error load DocsAPI from ${base}`))
}
document.body.appendChild(script)
})
return docsApiLoad
}
function destroyDocEditor(id: string) {
const inst = window.DocEditor?.instances?.[id]
if (inst) {
try {
inst.destroyEditor()
} catch {
/* ignore */
}
}
document.getElementById(id)?.replaceChildren()
}
function OnlyOfficeMount({
editorId,
documentServerUrl,
config,
onError,
}: {
editorId: string
documentServerUrl: string
config: Record<string, unknown>
onError: (message: string) => void
}) {
const configJson = JSON.stringify(config)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
useEffect(() => {
let cancelled = false
const id = editorId
const parsed = JSON.parse(configJson) as Record<string, unknown>
const editorConfig: Record<string, unknown> = {
type: "desktop",
width: "100%",
height: "100%",
events: {
onError: (event: { data?: { errorDescription?: string; errorCode?: number } }) => {
const msg =
event?.data?.errorDescription ||
(event?.data?.errorCode != null
? `OnlyOffice error ${event.data.errorCode}`
: "Erreur OnlyOffice.")
onErrorRef.current(msg)
},
},
...parsed,
}
void loadDocsApi(documentServerUrl)
.then(() => {
if (cancelled || !window.DocsAPI) return
destroyDocEditor(id)
if (!window.DocEditor) window.DocEditor = { instances: {} }
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
window.DocEditor.instances[id] = editor
})
.catch((err: unknown) => {
if (!cancelled) {
onErrorRef.current(
err instanceof Error ? err.message : "Impossible de charger OnlyOffice."
)
}
})
return () => {
cancelled = true
destroyDocEditor(id)
}
}, [editorId, documentServerUrl, configJson])
return <div id={editorId} className="h-full w-full min-h-0" />
}
export function PublicOfficeEditor({
token,
filePath,
password,
returnTo,
mode = "edit",
}: {
token: string
filePath: string
password?: string
returnTo?: string | null
mode?: "edit" | "view"
}) {
const instanceSeq = useRef(0)
const guestId = useRef(
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `guest-${Date.now()}`
)
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath]
)
useEffect(() => {
let cancelled = false
setConfig(null)
setServerUrl("")
setEditorId(null)
setError(null)
void (async () => {
try {
const res = await fetch(
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/office/session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: filePath,
mode,
password: password ?? "",
guest_id: guestId.current,
}),
}
)
if (!res.ok) throw new Error("session_failed")
const data = (await res.json()) as {
config: Record<string, unknown>
serverUrl: string
}
if (cancelled) return
instanceSeq.current += 1
setConfig(data.config)
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
} catch {
if (!cancelled) setError("Impossible de charger léditeur.")
}
})()
return () => {
cancelled = true
}
}, [token, filePath, password, mode])
const handleEditorError = useCallback((message: string) => {
setError(message)
}, [])
if (error) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-4">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour
</Link>
</Button>
</div>
)
}
if (!config || !editorId || !serverUrl) {
return <p className="p-8 text-center text-muted-foreground">Ouverture du document</p>
}
return (
<div className="flex h-dvh flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
Partage
</Link>
</Button>
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
</div>
<div className="relative min-h-0 flex-1">
<OnlyOfficeMount
editorId={editorId}
documentServerUrl={serverUrl.replace(/\/$/, "")}
config={config}
onError={handleEditorError}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,274 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import { useRouter } from "next/navigation"
import { Upload, FolderPlus } from "lucide-react"
import { toast } from "sonner"
import { FileBrowser } from "@/components/drive/file-browser"
import { DriveFilterBar } from "@/components/drive/drive-filter-bar"
import { DriveViewModeToggle } from "@/components/drive/drive-view-mode-toggle"
import { DriveSortMenu } from "@/components/drive/drive-sort-menu"
import { DriveBulkToolbar } from "@/components/drive/drive-bulk-toolbar"
import { DriveMarqueeSurface } from "@/components/drive/drive-marquee-surface"
import { DriveScrollEndSpacer } from "@/components/drive/drive-scroll-end-spacer"
import { Button } from "@/components/ui/button"
import { usePublicShareMenuMutations, usePublicShareMutations } from "@/lib/api/hooks/use-public-share-mutations"
import type { DriveFileInfo } from "@/lib/api/types"
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
import { useDriveFilteredItems } from "@/lib/hooks/use-drive-filtered-items"
import { useDriveFiltersStore } from "@/lib/stores/drive-filters-store"
import {
sharePermCanCreate,
sharePermCanDelete,
sharePermCanEdit,
sharePermCanUpdate,
} from "@/lib/drive/drive-share-permissions"
import { openPublicShareItem, downloadPublicShareFile } from "@/lib/drive/open-public-share-item"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import {
DRIVE_BROWSER_CARD_CLASS,
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
DRIVE_FILTER_CONTENT_GAP,
} from "@/lib/drive/drive-chrome-classes"
import { cn } from "@/lib/utils"
import { nextUntitledName } from "@/lib/drive/drive-default-name"
export function PublicShareFolderView({
token,
folderPath,
files,
permissions,
password,
folderTitle,
}: {
token: string
folderPath: string
files: DriveFileInfo[]
permissions: number
password?: string
folderTitle: string
}) {
const router = useRouter()
const openPreview = useDriveUIStore((s) => s.openPreview)
const selectedPaths = useDriveUIStore((s) => s.selectedPaths)
const clearSelection = useDriveUIStore((s) => s.clearSelection)
const sortField = useDriveSettingsStore((s) => s.sortField)
const sortDir = useDriveSettingsStore((s) => s.sortDir)
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
const filters = useDriveFiltersStore()
const uploadInputRef = useRef<HTMLInputElement>(null)
const canEdit = sharePermCanEdit(permissions)
const canCreate = sharePermCanCreate(permissions)
const canUpdate = sharePermCanUpdate(permissions)
const canDelete = sharePermCanDelete(permissions)
const writable = canUpdate || canDelete
const mutations = usePublicShareMenuMutations(token, password)
const { uploadFile, createFolder } = usePublicShareMutations(token, password)
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 { filteredItems: filteredFiles } = useDriveFilteredItems(
files,
filtersSnapshot,
{ sortField, sortDir, folderPlacement },
{ scopePath: folderPath }
)
const selectedTargets = useMemo(
() => filteredFiles.filter((f) => selectedPaths.has(f.path)),
[filteredFiles, selectedPaths]
)
const showBulk = selectedTargets.length > 0
useEffect(() => {
clearSelection()
}, [folderPath, clearSelection])
const openItem = (file: DriveFileInfo) => {
openPublicShareItem(file, {
token,
password,
canEdit: sharePermCanEdit(permissions),
router,
openPreview,
contextItems: filteredFiles,
})
}
const handleUpload = async (fileList: FileList | null) => {
if (!fileList?.length || !canCreate) return
const base = folderPath === "/" ? "" : folderPath
try {
for (const file of Array.from(fileList)) {
await uploadFile(`${base}/${file.name}`.replace(/\/+/g, "/"), file)
}
toast.success("Fichier(s) importé(s)")
} catch {
toast.error("Impossible dimporter le fichier")
}
}
const handleNewFolder = async () => {
if (!canCreate) return
const name = nextUntitledName(
filteredFiles.filter((f) => f.type === "directory").map((f) => f.name),
"Dossier"
)
const base = folderPath === "/" ? "" : folderPath
const path = `${base}/${name}`.replace(/\/+/g, "/")
try {
await createFolder.mutateAsync(path)
toast.success("Dossier créé")
} catch {
toast.error("Impossible de créer le dossier")
}
}
const downloadBulk = async (targets: DriveFileInfo[]) => {
for (const file of targets) {
await downloadPublicShareFile(token, file, password)
}
}
return (
<div className={DRIVE_BROWSER_CARD_CLASS}>
<div className={cn("shrink-0", DRIVE_FILTER_CONTENT_GAP)}>
<div
className={cn(
"flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 py-2",
DRIVE_CARD_PAD_X
)}
>
<p className="truncate text-sm text-muted-foreground">
<span className="font-medium text-[#3c4043] dark:text-[#e8eaed]">{folderTitle}</span>
{" · "}
{filteredFiles.length} élément{filteredFiles.length > 1 ? "s" : ""}
{canEdit ? " · Éditeur" : " · Lecture seule"}
</p>
<div className="flex flex-wrap items-center gap-2">
{canCreate ? (
<>
<input
ref={uploadInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void handleUpload(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
Importer
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => void handleNewFolder()}
>
<FolderPlus className="h-4 w-4" />
Dossier
</Button>
</>
) : null}
<DriveSortMenu />
<DriveViewModeToggle />
</div>
</div>
{showBulk ? (
<DriveBulkToolbar
targets={selectedTargets}
allowShare={false}
allowMove={false}
allowCopy={false}
allowQuickLink={false}
allowDelete={canDelete}
mutations={mutations}
onDownloadBulk={downloadBulk}
/>
) : (
<DriveFilterBar showContacts={false} />
)}
</div>
<main
data-drive-browser-main
className="flex min-h-0 flex-1 flex-col overflow-auto"
>
<DriveMarqueeSurface
enabled={filteredFiles.length > 0}
className="min-h-full"
>
{files.length === 0 ? (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-sm text-muted-foreground"
)}
>
Ce dossier est vide.
{canCreate ? " Importez un fichier ou créez un dossier." : ""}
</p>
) : filteredFiles.length === 0 ? (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-sm text-muted-foreground"
)}
>
Aucun élément ne correspond aux filtres.
</p>
) : (
<FileBrowser
items={filteredFiles}
view="shared"
allowShare={false}
writable={writable}
hideFavorite
disableDnd
mutations={mutations}
publicShare={{ token, password }}
onOpenItem={openItem}
onDownloadItem={(file) =>
void downloadPublicShareFile(token, file, password).catch(() =>
toast.error("Téléchargement impossible")
)
}
/>
)}
<DriveScrollEndSpacer />
</DriveMarqueeSurface>
</main>
</div>
)
}

View File

@ -0,0 +1,286 @@
"use client"
import dynamic from "next/dynamic"
import Link from "next/link"
import { useEffect, useState, type ReactNode } from "react"
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
import { toast } from "sonner"
import { PublicShareFolderView } from "@/components/drive/public-share-folder-view"
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
fetchPublicShareBlob,
publicShareDownloadApiPath,
publicShareHref,
publicShareOwnerLabel,
type PublicShareView,
} from "@/lib/api/public-share"
import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils"
const PdfPreviewViewer = dynamic(
() => import("@/components/drive/pdf-preview-viewer").then((m) => m.PdfPreviewViewer),
{ ssr: false }
)
function PublicShareBreadcrumb({
token,
rootShareName,
path,
}: {
token: string
rootShareName: string
path: string
}) {
const parts = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean)
const crumbs: { label: string; href: string }[] = [
{ label: rootShareName || "Partage", href: publicShareHref(token) },
]
let acc = ""
for (const part of parts) {
acc += `/${part}`
crumbs.push({ label: part, href: publicShareHref(token, acc) })
}
return (
<nav
className="flex flex-wrap items-center gap-1 text-sm"
aria-label="Fil d'Ariane"
>
{crumbs.map((crumb, i) => (
<span key={crumb.href} className="flex items-center gap-1">
{i > 0 ? <ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden /> : null}
{i === crumbs.length - 1 ? (
<span className="font-medium text-[#3c4043] dark:text-[#e8eaed]">{crumb.label}</span>
) : (
<Link href={crumb.href} className="text-[#1967d2] hover:underline dark:text-[#8ab4f8]">
{crumb.label}
</Link>
)}
</span>
))}
</nav>
)
}
function usePublicShareRootName(token: string, path: string, currentName: string) {
const storageKey = `public-share-root:${token}`
const [rootName, setRootName] = useState(currentName)
useEffect(() => {
if (typeof window === "undefined") return
if (path === "/" && currentName) {
sessionStorage.setItem(storageKey, currentName)
setRootName(currentName)
return
}
const stored = sessionStorage.getItem(storageKey)
if (stored) setRootName(stored)
}, [token, path, currentName, storageKey])
return rootName
}
function PublicFilePreview({
token,
file,
password,
}: {
token: string
file: DriveFileInfo
password?: string
}) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [textContent, setTextContent] = useState<string | null>(null)
const [svgMarkup, setSvgMarkup] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const kind = drivePreviewKind(file)
const isSvg = isSvgFile(file)
useEffect(() => {
let cancelled = false
let objectUrl: string | null = null
setLoading(true)
void fetchPublicShareBlob(token, file, password)
.then(async (blob) => {
if (cancelled) return
if (kind === "text") {
setTextContent(await blob.text())
setBlobUrl(null)
setSvgMarkup(null)
} else if (isSvg) {
setSvgMarkup(await blob.text())
setBlobUrl(null)
setTextContent(null)
} else {
objectUrl = URL.createObjectURL(blob)
setBlobUrl(objectUrl)
setTextContent(null)
setSvgMarkup(null)
}
})
.catch(() => toast.error("Aperçu indisponible"))
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [token, file, password, kind, isSvg])
if (loading) {
return (
<div className="flex h-[min(60vh,520px)] items-center justify-center rounded-2xl border border-border bg-[#f8f9fa] dark:bg-[#35363a]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (isSvg && svgMarkup) {
return (
<div className="flex max-h-[min(70vh,640px)] min-h-[240px] items-stretch overflow-hidden rounded-2xl border border-border bg-[#f8f9fa] dark:bg-[#35363a]">
<SvgPreviewViewer markup={svgMarkup} name={file.name} className="min-h-[240px]" />
</div>
)
}
if (kind === "image" && blobUrl) {
return (
<div className="flex max-h-[min(70vh,640px)] items-center justify-center overflow-hidden rounded-2xl border border-border bg-[#f8f9fa] p-4 dark:bg-[#35363a]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={blobUrl} alt={file.name} className="max-h-full max-w-full object-contain" />
</div>
)
}
if (kind === "video" && blobUrl) {
return (
<div className="overflow-hidden rounded-2xl border border-border bg-black">
<video src={blobUrl} controls className="max-h-[min(70vh,640px)] w-full" />
</div>
)
}
if (kind === "audio" && blobUrl) {
return (
<div className="flex items-center justify-center rounded-2xl border border-border bg-[#f8f9fa] px-8 py-12 dark:bg-[#35363a]">
<audio src={blobUrl} controls className="w-full max-w-lg" />
</div>
)
}
if (kind === "pdf" && blobUrl) {
return (
<div className="h-[min(70vh,640px)] overflow-hidden rounded-2xl border border-border">
<PdfPreviewViewer blobUrl={blobUrl} name={file.name} />
</div>
)
}
if (kind === "text" && textContent != null) {
return (
<pre className="max-h-[min(60vh,520px)] overflow-auto rounded-2xl border border-border bg-[#f8f9fa] p-4 text-sm dark:bg-[#35363a]">
{textContent}
</pre>
)
}
return (
<div className="rounded-2xl border border-dashed border-border bg-[#f8f9fa] px-6 py-10 text-center dark:bg-[#35363a]">
<p className="text-sm text-muted-foreground">Aperçu non disponible pour ce type de fichier.</p>
</div>
)
}
export function PublicShareViewPanel({
token,
path,
data,
password,
}: {
token: string
path: string
data: PublicShareView
password?: string
}) {
const file = data.item_type === "file" ? data.file : null
const files = data.item_type === "folder" ? (data.files ?? []) : []
const rootShareName = usePublicShareRootName(token, path, data.name)
const sharedByLabel = publicShareOwnerLabel(data)
const downloadCurrent = () => {
if (!file) return
const url = publicShareDownloadApiPath(token, file.path, password)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = file.name
anchor.rel = "noopener"
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
return (
<div
className={cn(
"mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col pb-4",
PUBLIC_SHARE_INSET_X,
"pt-3"
)}
>
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">
Partagé par <span className="font-medium text-[#3c4043] dark:text-[#e8eaed]">{sharedByLabel}</span>
</p>
{data.item_type === "folder" ? (
<PublicShareBreadcrumb token={token} rootShareName={rootShareName} path={path} />
) : null}
</div>
{file ? (
<Button type="button" variant="outline" size="sm" className="gap-2 shrink-0" onClick={downloadCurrent}>
<Download className="h-4 w-4" />
Télécharger
</Button>
) : null}
</div>
{data.item_type === "folder" ? (
<PublicShareFolderView
token={token}
folderPath={path}
files={files}
permissions={data.permissions ?? 1}
password={password}
folderTitle={data.name}
/>
) : file ? (
<PublicFilePreview token={token} file={file} password={password} />
) : null}
<FilePreviewDialog />
</div>
)
}
export function PublicShareChrome({ children }: { children: ReactNode }) {
return (
<SuiteThemeShell>
<div className="ultimail-app flex h-dvh flex-col overflow-hidden bg-app-canvas" data-drive-app>
<header className="flex h-16 shrink-0 items-center border-b border-border bg-mail-surface px-4 sm:px-6">
<Link href="/drive" className="flex items-center gap-2.5">
<img src={suitePublicAsset("/ultidrive-mark.svg")} alt="" className="h-8 w-8" />
<span className="text-lg font-medium text-[#3c4043] dark:text-[#e8eaed]">UltiDrive</span>
</Link>
<span className="ml-3 hidden text-sm text-muted-foreground sm:inline">
<FolderOpen className="mr-1 inline h-4 w-4 align-[-2px]" aria-hidden />
Lien de partage
</span>
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</main>
</div>
</SuiteThemeShell>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { useDriveQuota } from "@/lib/api/hooks/use-drive-queries"
function formatBytes(n: number) {
if (n < 1024) return `${n} o`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} Ko`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} Mo`
return `${(n / 1024 ** 3).toFixed(1)} Go`
}
export function DriveQuotaBar() {
const { data } = useDriveQuota()
if (!data || data.total <= 0) return null
const usedPct = Math.min(100, Math.round((data.used / data.total) * 100))
return (
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>Stockage</span>
<span>{formatBytes(data.used)} / {formatBytes(data.total)}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${usedPct}%` }} />
</div>
</div>
)
}

View File

@ -0,0 +1,789 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Icon } from "@iconify/react"
import {
Building2,
Copy,
Eye,
Link2,
Loader2,
Mail,
Pencil,
RefreshCw,
Shield,
SlidersHorizontal,
Trash2,
UserRound,
Users,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { useSearchContacts } from "@/lib/api/hooks/use-contact-queries"
import { useDriveShares, useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo, DriveShare } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import {
FOLDER_SHARE_PERMISSION_OPTIONS,
folderPermissionsFromRole,
folderPermissionsToBitmask,
type FolderSharePermissionId,
type FolderSharePermissions,
} from "@/lib/drive/drive-share-permissions"
import {
NC_SHARE_TYPE,
SHARE_SECTION_LABELS,
groupSharesBySection,
shareAccessLabel,
shareLinkForCopy,
shareMetaLine,
shareOwnerLabel,
shareRecipientLabel,
type DriveShareMode,
type ShareListSection,
} from "@/lib/drive/drive-share-types"
import { DriveFileTypeIcon } from "@/lib/drive/drive-file-icon"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import {
DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY,
DRIVE_CARD_ACTIVE,
DRIVE_CARD_IDLE,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_DIVIDER,
DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER,
DRIVE_DIALOG_OVERLAY,
DRIVE_FIELD_CLASS,
DRIVE_LABEL_CLASS,
DRIVE_PANEL_MUTED,
DRIVE_TEXT_PRIMARY,
DRIVE_TEXT_SECONDARY,
DRIVE_TEXT_TITLE,
DRIVE_TEXTAREA_CLASS,
} from "@/lib/drive/drive-dialog-styles"
import { cn } from "@/lib/utils"
function shareItemLabel(path: string) {
const trimmed = path.replace(/\/+$/, "")
const base = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return displayFileName(base || path)
}
type SharePermissionMode = "viewer" | "editor" | "advanced"
const PERMISSION_MODE_OPTIONS: {
id: SharePermissionMode
label: string
description: string
icon: typeof Eye
folderOnly?: boolean
}[] = [
{
id: "viewer",
label: "Lecteur",
description: "Consultation uniquement",
icon: Eye,
},
{
id: "editor",
label: "Éditeur",
description: "Peut modifier le contenu",
icon: Pencil,
},
{
id: "advanced",
label: "Avancé",
description: "Définir chaque autorisation",
icon: SlidersHorizontal,
folderOnly: true,
},
]
const MODE_OPTIONS: {
id: DriveShareMode
label: string
description: string
icon: typeof Link2
}[] = [
{
id: "contact",
label: "Personne",
description: "Partage direct par e-mail ou compte",
icon: UserRound,
},
{
id: "internal",
label: "Lien interne",
description: "Réservé aux utilisateurs inscrits connectés",
icon: Users,
},
{
id: "public",
label: "Lien public",
description: "Accessible à toute personne disposant du lien",
icon: Link2,
},
]
function shareSectionIcon(section: ShareListSection) {
if (section === "people") return UserRound
if (section === "groups") return Building2
return Link2
}
function ShareEntryRow({
share,
onDelete,
deleting,
}: {
share: DriveShare
onDelete: () => void
deleting: boolean
}) {
const url = shareLinkForCopy(share)
const recipient = shareRecipientLabel(share)
const owner = shareOwnerLabel(share)
const meta = shareMetaLine(share)
const accessLabel = shareAccessLabel(share)
const isLink = share.share_type === NC_SHARE_TYPE.LINK
const copy = async () => {
if (!url) return
try {
await navigator.clipboard.writeText(url)
toast.success("Lien copié")
} catch {
toast.error("Impossible de copier le lien")
}
}
const primaryLine = recipient ?? (url && isLink ? url : accessLabel)
return (
<div className={cn(DRIVE_PANEL_MUTED, "rounded-xl px-3 py-3")}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-md bg-[#e8f0fe] px-2 py-0.5 text-[11px] font-medium text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
{accessLabel}
</span>
{share.has_password ? (
<span className="inline-flex items-center gap-1 text-[11px] text-[#5f6368] dark:text-[#9aa0a6]">
<Shield className="h-3 w-3" aria-hidden />
Mot de passe
</span>
) : null}
</div>
<p className={cn("text-sm font-medium leading-snug", DRIVE_TEXT_PRIMARY)}>
{primaryLine}
</p>
{url && isLink && recipient ? (
<p className={cn("truncate text-xs", DRIVE_TEXT_SECONDARY)}>{url}</p>
) : null}
{owner ? (
<p className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>
Propriétaire · {owner}
</p>
) : null}
<p className={cn("text-xs capitalize", DRIVE_TEXT_SECONDARY)}>{meta}</p>
{share.note?.trim() ? (
<p className={cn("border-t pt-1.5 text-xs italic", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
« {share.note.trim()} »
</p>
) : null}
</div>
<div className="flex shrink-0 items-start gap-0.5">
{url ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={DRIVE_BTN_GHOST}
aria-label="Copier le lien"
onClick={() => void copy()}
>
<Copy className="h-4 w-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(DRIVE_TEXT_SECONDARY, "hover:bg-[#fce8e6] hover:text-[#d93025] dark:hover:bg-[#5c2b29]/50")}
aria-label="Supprimer le partage"
disabled={deleting}
onClick={onDelete}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)
}
function ActiveSharesPanel({
shares,
loading,
error,
onRetry,
deletingShareId,
onDeleteShare,
}: {
shares: DriveShare[]
loading: boolean
error: boolean
onRetry: () => void
deletingShareId: string | null
onDeleteShare: (shareId: string) => void
}) {
const grouped = useMemo(() => groupSharesBySection(shares), [shares])
const sectionOrder: ShareListSection[] = ["links", "people", "groups"]
const hasShares = shares.length > 0
return (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between gap-2">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Accès existants</p>
{!loading ? (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")}
onClick={onRetry}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
Actualiser
</Button>
) : null}
</div>
{loading ? (
<div className={cn("flex items-center gap-2 text-sm", DRIVE_TEXT_SECONDARY)}>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Chargement des partages
</div>
) : error ? (
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 rounded-xl px-3 py-3")}>
<p className={cn("text-sm", DRIVE_TEXT_PRIMARY)}>Impossible de charger les partages existants.</p>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(DRIVE_BTN_GHOST, "h-8 px-2 text-xs")}
onClick={onRetry}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
Réessayer
</Button>
</div>
) : !hasShares ? (
<p className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
Aucun partage actif pour cet élément. Créez un lien ou invitez une personne ci-dessus.
</p>
) : (
<div className="max-h-52 space-y-4 overflow-y-auto pr-0.5">
{sectionOrder.map((section) => {
const items = grouped[section]
if (items.length === 0) return null
const SectionIcon = shareSectionIcon(section)
return (
<div key={section} className="space-y-2">
<p className={cn("flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide", DRIVE_TEXT_SECONDARY)}>
<SectionIcon className="h-3.5 w-3.5" aria-hidden />
{SHARE_SECTION_LABELS[section]}
<span className="font-normal normal-case tracking-normal">({items.length})</span>
</p>
<div className="space-y-2">
{items.map((share) => (
<ShareEntryRow
key={share.id}
share={share}
deleting={deletingShareId === share.id}
onDelete={() => onDeleteShare(share.id)}
/>
))}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
function SharePermissionsPanel({
isFolder,
permissionMode,
folderPermissions,
onPermissionModeChange,
onFolderPermissionChange,
}: {
isFolder: boolean
permissionMode: SharePermissionMode
folderPermissions: FolderSharePermissions
onPermissionModeChange: (mode: SharePermissionMode) => void
onFolderPermissionChange: (id: FolderSharePermissionId, checked: boolean) => void
}) {
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
const modeOptions = PERMISSION_MODE_OPTIONS.filter((option) => isFolder || !option.folderOnly)
return (
<div className="space-y-2.5">
<div className={cn("grid gap-2", isFolder ? "grid-cols-3" : "grid-cols-2")}>
{modeOptions.map((option) => {
const IconComponent = option.icon
const selected = permissionMode === option.id
return (
<button
key={option.id}
type="button"
onClick={() => onPermissionModeChange(option.id)}
className={cn(
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-3 py-3 text-left transition-colors",
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
)}
>
<span className="flex items-center gap-2">
<IconComponent
className={cn(
"h-4 w-4",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
)}
aria-hidden
/>
<span
className={cn(
"text-sm font-medium",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
)}
>
{option.label}
</span>
</span>
<span className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>{option.description}</span>
</button>
)
})}
</div>
{isFolder && permissionMode === "advanced" ? (
<div className={cn(DRIVE_PANEL_MUTED, "space-y-2 px-3 py-3")}>
{FOLDER_SHARE_PERMISSION_OPTIONS.map((option) => {
const checked = folderPermissions[option.id]
const checkboxId = `drive-share-perm-${option.id}`
return (
<div key={option.id} className="flex items-center gap-3 rounded-lg py-1">
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(value) =>
onFolderPermissionChange(option.id, value === true)
}
/>
<Label
htmlFor={checkboxId}
className={cn("cursor-pointer text-sm font-normal", DRIVE_TEXT_PRIMARY)}
>
{option.label}
</Label>
</div>
)
})}
{!folderPermissions.viewContent && folderPermissions.addFiles ? (
<p className={cn("border-t pt-2 text-xs leading-relaxed", DRIVE_DIALOG_DIVIDER, DRIVE_TEXT_SECONDARY)}>
Dépôt uniquement : les visiteurs pourront ajouter des fichiers sans voir le contenu
existant du dossier.
</p>
) : null}
{advancedPermissionBits === 0 ? (
<p className="text-xs text-[#d93025]" role="alert">
Sélectionnez au moins une autorisation.
</p>
) : null}
</div>
) : null}
</div>
)
}
export function ShareDialog() {
const path = useDriveUIStore((s) => s.sharePath)
const shareItemType = useDriveUIStore((s) => s.shareItemType)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const [mode, setMode] = useState<DriveShareMode>("public")
const [permissionMode, setPermissionMode] = useState<SharePermissionMode>("viewer")
const [folderPermissions, setFolderPermissions] = useState<FolderSharePermissions>(
() => folderPermissionsFromRole("viewer")
)
const [contactEmail, setContactEmail] = useState("")
const [contactQuery, setContactQuery] = useState("")
const [contactNote, setContactNote] = useState("")
const [recipientRegistered, setRecipientRegistered] = useState<boolean | null>(null)
const [deletingShareId, setDeletingShareId] = useState<string | null>(null)
const { data, isLoading: sharesLoading, isError: sharesError, refetch: refetchShares } = useDriveShares(
path ?? "",
Boolean(path)
)
const { createShare, deleteShare, lookupShareRecipient } = useDriveMutations()
const lookupRecipientEmail = lookupShareRecipient.mutateAsync
const { data: contactResults = [] } = useSearchContacts(contactQuery)
const itemLabel = useMemo(() => (path ? shareItemLabel(path) : ""), [path])
const isFolder = shareItemType === "directory"
const selectedMode = MODE_OPTIONS.find((m) => m.id === mode) ?? MODE_OPTIONS[0]
const filePreview = useMemo((): DriveFileInfo | null => {
if (!path) return null
return {
path,
name: itemLabel,
type: shareItemType ?? "file",
size: 0,
mime_type: "",
last_modified: "",
etag: "",
is_favorite: false,
}
}, [path, itemLabel, shareItemType])
useEffect(() => {
if (path) {
setMode("public")
setPermissionMode("viewer")
setFolderPermissions(folderPermissionsFromRole("viewer"))
setContactEmail("")
setContactQuery("")
setContactNote("")
setRecipientRegistered(null)
}
}, [path])
useEffect(() => {
const email = contactEmail.trim().toLowerCase()
if (mode !== "contact" || !email.includes("@")) {
setRecipientRegistered(null)
return
}
const timer = window.setTimeout(() => {
lookupRecipientEmail(email)
.then((res) => setRecipientRegistered(res.registered))
.catch(() => setRecipientRegistered(null))
}, 350)
return () => window.clearTimeout(timer)
}, [contactEmail, mode, lookupRecipientEmail])
const advancedPermissionBits = folderPermissionsToBitmask(folderPermissions)
const canCreateShare =
(mode !== "contact" || contactEmail.trim().includes("@")) &&
(!isFolder || permissionMode !== "advanced" || advancedPermissionBits > 0)
const setFolderPermission = (id: FolderSharePermissionId, checked: boolean) => {
setFolderPermissions((prev) => ({ ...prev, [id]: checked }))
}
const onPermissionModeChange = (nextMode: SharePermissionMode) => {
setPermissionMode(nextMode)
if (nextMode === "advanced") {
setFolderPermissions(folderPermissionsFromRole(permissionMode === "editor" ? "editor" : "viewer"))
}
}
const close = () => setSharePath(null, null)
const sharePayload = () => {
const base =
isFolder && permissionMode === "advanced"
? { path: path!, permissions: advancedPermissionBits }
: { path: path!, role: permissionMode === "editor" ? "editor" : "viewer" }
return base
}
const onShare = async () => {
if (!path || !canCreateShare) return
try {
const payload = sharePayload()
const share = await createShare.mutateAsync({
...payload,
mode,
...(mode === "contact"
? {
share_with: contactEmail.trim().toLowerCase(),
note: contactNote.trim() || undefined,
send_mail: true,
}
: {}),
})
if (mode === "contact") {
if (share.access_mode === "user" || share.share_type === NC_SHARE_TYPE.USER) {
toast.success("Partagé — visible dans « Partagés avec moi » du destinataire")
} else {
toast.success("Invitation envoyée par e-mail avec un lien public")
}
setContactEmail("")
setContactNote("")
setContactQuery("")
} else {
const link = shareLinkForCopy(share)
if (link) {
await navigator.clipboard.writeText(link)
toast.success(
mode === "internal"
? "Lien interne copié dans le presse-papiers"
: "Lien public copié dans le presse-papiers"
)
} else {
toast.success("Partage créé")
}
}
void refetchShares()
} catch {
toast.error("Partage impossible")
}
}
const onDeleteShare = async (shareId: string) => {
setDeletingShareId(shareId)
try {
await deleteShare.mutateAsync(shareId)
toast.success("Partage supprimé")
void refetchShares()
} catch {
toast.error("Suppression impossible")
} finally {
setDeletingShareId(null)
}
}
const existingShares = data?.shares ?? []
const actionLabel =
mode === "contact"
? "Partager"
: mode === "internal"
? "Créer le lien interne"
: "Créer le lien public"
return (
<Dialog open={Boolean(path)} onOpenChange={(open) => !open && close()}>
<DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[520px]")}
>
<DialogHeader className={cn(DRIVE_DIALOG_HEADER, "space-y-4")}>
<div className="flex items-start gap-3 pr-8">
{filePreview ? (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#f1f3f4] dark:bg-[#35363a]">
<DriveFileTypeIcon file={filePreview} size="md" />
</div>
) : (
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#e8f0fe] text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
<Icon icon="mdi:link-variant" className="h-5 w-5" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1">
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
{isFolder ? "Partager le dossier" : "Partager le fichier"}
</DialogTitle>
<DialogDescription className={cn("mt-1 truncate text-sm", DRIVE_TEXT_SECONDARY)}>
{itemLabel}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="max-h-[min(70vh,560px)] space-y-5 overflow-y-auto px-6 py-5">
<div className="grid grid-cols-3 gap-2">
{MODE_OPTIONS.map((option) => {
const IconComponent = option.icon
const selected = mode === option.id
return (
<button
key={option.id}
type="button"
onClick={() => setMode(option.id)}
className={cn(
"flex cursor-pointer flex-col items-start gap-1 rounded-xl border px-2.5 py-2.5 text-left transition-colors",
selected ? DRIVE_CARD_ACTIVE : DRIVE_CARD_IDLE
)}
>
<span className="flex items-center gap-1.5">
<IconComponent
className={cn(
"h-3.5 w-3.5",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_SECONDARY
)}
aria-hidden
/>
<span
className={cn(
"text-xs font-medium",
selected ? "text-[#1967d2] dark:text-[#8ab4f8]" : DRIVE_TEXT_PRIMARY
)}
>
{option.label}
</span>
</span>
</button>
)
})}
</div>
<div className="space-y-3">
<div>
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>{selectedMode.label}</p>
<p className={cn("mt-1 text-xs leading-relaxed", DRIVE_TEXT_SECONDARY)}>
{mode === "public"
? `Toute personne disposant du lien pourra accéder à ${isFolder ? "ce dossier" : "ce fichier"} selon le rôle choisi.`
: mode === "internal"
? `Seuls les utilisateurs inscrits et connectés pourront ouvrir ce lien vers ${isFolder ? "ce dossier" : "ce fichier"}.`
: "Si le destinataire possède un compte, le fichier apparaît dans ses « Partagés avec moi ». Sinon, il reçoit un e-mail avec un lien public."}
</p>
</div>
{mode === "contact" ? (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="drive-share-contact-email" className={DRIVE_LABEL_CLASS}>
Adresse e-mail
</Label>
<Input
id="drive-share-contact-email"
type="email"
value={contactEmail}
onChange={(e) => {
setContactEmail(e.target.value)
setContactQuery(e.target.value)
}}
placeholder="nom@exemple.com"
autoComplete="off"
className={DRIVE_FIELD_CLASS}
/>
{contactQuery.length >= 2 && contactResults.length > 0 ? (
<div className="max-h-32 overflow-y-auto rounded-lg border border-[#dadce0] bg-[#f8f9fa] dark:border-[#5f6368]/40 dark:bg-[#35363a]">
{contactResults.slice(0, 6).map((c) =>
c.email ? (
<button
key={c.uid}
type="button"
className="flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50"
onClick={() => {
setContactEmail(c.email!)
setContactQuery("")
}}
>
<span className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>{c.full_name}</span>
<span className={cn("text-xs", DRIVE_TEXT_SECONDARY)}>{c.email}</span>
</button>
) : null
)}
</div>
) : null}
{recipientRegistered === true ? (
<p className="flex items-center gap-1.5 text-xs text-[#188038] dark:text-[#81c995]">
<UserRound className="h-3.5 w-3.5" aria-hidden />
Compte inscrit partage direct dans « Partagés avec moi »
</p>
) : null}
{recipientRegistered === false ? (
<p className={cn("flex items-center gap-1.5 text-xs", DRIVE_TEXT_SECONDARY)}>
<Mail className="h-3.5 w-3.5" aria-hidden />
Pas de compte invitation par e-mail avec lien public
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="drive-share-contact-note" className={DRIVE_LABEL_CLASS}>
Message (optionnel)
</Label>
<Textarea
id="drive-share-contact-note"
value={contactNote}
onChange={(e) => setContactNote(e.target.value)}
placeholder="Ajouter un message pour le destinataire…"
rows={2}
className={DRIVE_TEXTAREA_CLASS}
/>
</div>
</div>
) : null}
<SharePermissionsPanel
isFolder={isFolder}
permissionMode={permissionMode}
folderPermissions={folderPermissions}
onPermissionModeChange={onPermissionModeChange}
onFolderPermissionChange={setFolderPermission}
/>
</div>
<ActiveSharesPanel
shares={existingShares}
loading={sharesLoading}
error={sharesError}
onRetry={() => void refetchShares()}
deletingShareId={deletingShareId}
onDeleteShare={(shareId) => void onDeleteShare(shareId)}
/>
</div>
<DialogFooter className={DRIVE_DIALOG_FOOTER}>
<Button
type="button"
variant="ghost"
className={DRIVE_BTN_GHOST}
onClick={close}
>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
disabled={createShare.isPending || !canCreateShare}
onClick={() => void onShare()}
>
{createShare.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{mode === "contact" ? "Envoi…" : "Création…"}
</>
) : mode === "contact" ? (
<>
<Mail className="h-4 w-4" />
{actionLabel}
</>
) : (
<>
<Link2 className="h-4 w-4" />
{actionLabel}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,282 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import type { LucideIcon } from "lucide-react"
import { ChevronRight, HardDrive, Users } from "lucide-react"
import { DRIVE_DROP_TARGET_CLASS } from "@/components/drive/drive-file-context-menu"
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
import { DRIVE_ICON_BTN } from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import type { DriveView } from "@/lib/drive/drive-url"
import { folderPathFromSegments } from "@/lib/drive/drive-url"
import { useDriveList, useDriveSharedWithMe } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import {
ancestorFolderPaths,
driveFolderHref,
isSharedRootSelected,
normalizeDriveFolderPath,
selectedFolderPath,
} from "@/lib/drive/drive-sidebar-tree"
import { useIsMobile } from "@/hooks/use-mobile"
import { useDriveDropTarget } from "@/lib/hooks/use-drive-drop-target"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
const INDENT_PX = 16
function useFolderChildren(folderPath: string, enabled: boolean, sharedRoot: boolean) {
const list = useDriveList(folderPath, 1, "", enabled && !sharedRoot)
const shared = useDriveSharedWithMe(1, "", enabled && sharedRoot)
const active = sharedRoot ? shared : list
const directories =
active.data?.files.filter((file) => file.type === "directory") ?? []
return { directories }
}
function SidebarTreeCaret({
visible,
expanded,
onToggle,
label,
}: {
visible: boolean
expanded: boolean
onToggle: () => void
label: string
}) {
if (!visible) {
return <span className="w-6 shrink-0" aria-hidden="true" />
}
return (
<button
type="button"
aria-label={label}
className={cn(
"flex h-7 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md",
DRIVE_ICON_BTN
)}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
expanded && "rotate-90"
)}
/>
</button>
)
}
function SidebarFolderNode({
folder,
depth,
view,
currentPath,
active,
}: {
folder: DriveFileInfo
depth: number
view: DriveView
currentPath: string
active: boolean
}) {
const router = useRouter()
const isMobile = useIsMobile()
const folderPath = normalizeDriveFolderPath(folder.path)
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const ensureSidebarPathsExpanded = useDriveUIStore((s) => s.ensureSidebarPathsExpanded)
const isExpanded = expandedPaths.has(folderPath)
const isSelected = active && currentPath === folderPath
const { directories } = useFolderChildren(folderPath, true, false)
const hasChildFolders = directories.length > 0
const href = driveFolderHref(view, folderPath)
const label = displayFileName(folder.name)
const { dropProps, canDrop, isOver } = useDriveDropTarget({
folderPath,
disabled: isMobile,
hasChildFolders,
onExpandRequest: () => {
if (!isExpanded) ensureSidebarPathsExpanded([folderPath])
},
})
return (
<div className="min-w-0">
<div
className={cn(
"group flex min-w-0 items-center rounded-lg text-sm",
mailNavRowClass({ isSelected }),
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
)}
style={{ paddingLeft: depth * INDENT_PX }}
{...dropProps}
>
<SidebarTreeCaret
visible={hasChildFolders}
expanded={isExpanded}
label={isExpanded ? "Replier le dossier" : "Déplier le dossier"}
onToggle={() => toggleSidebarPath(folderPath)}
/>
<Link
href={href}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1.5 pr-2"
onClick={(event) => {
event.preventDefault()
router.push(href)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<DriveFolderIcon file={folder} inSharedView={view === "shared"} size="sm" />
<span className="truncate">{label}</span>
</Link>
</div>
{isExpanded && hasChildFolders
? directories.map((child) => (
<SidebarFolderNode
key={child.path}
folder={child}
depth={depth + 1}
view={view}
currentPath={currentPath}
active={active}
/>
))
: null}
</div>
)
}
function SidebarRootBranch({
view,
rootHref,
rootLabel,
rootIcon: RootIcon,
rootKey,
pathSegments,
active,
}: {
view: DriveView
rootHref: string
rootLabel: string
rootIcon: LucideIcon
rootKey: string
pathSegments: string[]
active: boolean
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const ensureSidebarPathsExpanded = useDriveUIStore((s) => s.ensureSidebarPathsExpanded)
const currentPath = active ? selectedFolderPath(view, pathSegments) : ""
const isRootSelected =
active &&
(view === "shared" ? isSharedRootSelected(view, pathSegments) : currentPath === "/")
const isExpanded = expandedPaths.has(rootKey)
const sharedRoot = view === "shared"
const { directories } = useFolderChildren("/", true, sharedRoot)
const hasChildFolders = directories.length > 0
const { dropProps, canDrop, isOver } = useDriveDropTarget({
folderPath: "/",
disabled: isMobile,
hasChildFolders,
onExpandRequest: () => {
if (!isExpanded) ensureSidebarPathsExpanded([rootKey])
},
})
useEffect(() => {
if (!active) return
ensureSidebarPathsExpanded(ancestorFolderPaths(folderPathFromSegments(pathSegments)))
ensureSidebarPathsExpanded([rootKey])
}, [active, ensureSidebarPathsExpanded, pathSegments, rootKey])
return (
<div className="min-w-0">
<div
className={cn(
"group flex min-w-0 items-center rounded-lg text-sm",
mailNavRowClass({ isSelected: isRootSelected }),
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
)}
{...dropProps}
>
<SidebarTreeCaret
visible={hasChildFolders}
expanded={isExpanded}
label={isExpanded ? "Replier" : "Déplier"}
onToggle={() => toggleSidebarPath(rootKey)}
/>
<Link
href={rootHref}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1.5 pr-2"
onClick={(event) => {
event.preventDefault()
router.push(rootHref)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<RootIcon className="h-4 w-4 shrink-0" />
<span className="truncate">{rootLabel}</span>
</Link>
</div>
{isExpanded && hasChildFolders
? directories.map((folder) => (
<SidebarFolderNode
key={folder.path}
folder={folder}
depth={1}
view={view}
currentPath={currentPath}
active={active}
/>
))
: null}
</div>
)
}
export function DriveSidebarFolderTree({
view,
pathSegments,
active,
}: {
view: "files" | "shared"
pathSegments: string[]
active: boolean
}) {
if (view === "files") {
return (
<SidebarRootBranch
view="files"
rootHref="/drive"
rootLabel="Mon Drive"
rootIcon={HardDrive}
rootKey="/"
pathSegments={pathSegments}
active={active}
/>
)
}
return (
<SidebarRootBranch
view="shared"
rootHref="/drive/shared"
rootLabel="Partagés avec moi"
rootIcon={Users}
rootKey="/__shared_root__"
pathSegments={pathSegments}
active={active}
/>
)
}

View File

@ -0,0 +1,32 @@
"use client"
import { displayFileName } from "@/lib/drive/display-file-name"
import { cn } from "@/lib/utils"
type SvgPreviewViewerProps = {
markup: string
name: string
className?: string
}
function svgToSrcDoc(markup: string): string {
const trimmed = markup.trim()
const lower = trimmed.toLowerCase()
if (lower.startsWith("<!doctype") || lower.startsWith("<html")) return trimmed
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>html,body{margin:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:transparent}svg{max-width:100%;max-height:100%;width:auto;height:auto}</style></head><body>${trimmed}</body></html>`
}
/** Sandboxed inline SVG — avoids blob MIME issues (Nextcloud often serves text/xml). */
export function SvgPreviewViewer({ markup, name, className }: SvgPreviewViewerProps) {
return (
<iframe
title={displayFileName(name)}
srcDoc={svgToSrcDoc(markup)}
sandbox=""
className={cn(
"h-full min-h-[120px] w-full max-w-full flex-1 border-0 bg-transparent",
className
)}
/>
)
}

View File

@ -55,6 +55,7 @@ import {
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_ICON_BTN_CLASS, CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_SAVE_BTN_CLASS, CONTACTS_PAGE_SAVE_BTN_CLASS,
CONTACTS_MENU_SURFACE_CLASS,
CONTACTS_PANEL_ADD_TAG_BTN_CLASS, CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_CARD_CLASS, CONTACTS_PANEL_CARD_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS, CONTACTS_PANEL_FLOATING_INPUT_CLASS,
@ -329,7 +330,11 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
<Plus className="h-3 w-3" /> Libellé <Plus className="h-3 w-3" /> Libellé
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-52 p-1" align="center"> <PopoverContent
data-contacts-menu-surface
className={cn("w-52 p-1", CONTACTS_MENU_SURFACE_CLASS)}
align="center"
>
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p> <p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p>
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{availableLabels.map((row) => { {availableLabels.map((row) => {
@ -562,7 +567,7 @@ function CompactSelect({ value, onValueChange, options, placeholder }: { value:
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}> <SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder ?? "Choisir..."} /> <SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
{options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)} {options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -108,8 +108,12 @@ export function ContactsAppShell() {
return ( return (
<div <div
data-contacts-app
data-contacts-panel data-contacts-panel
className={cn("relative flex h-dvh max-h-dvh overflow-hidden", CONTACTS_SHELL_CLASS)} className={cn(
"ultimail-app relative flex h-dvh max-h-dvh overflow-hidden",
CONTACTS_SHELL_CLASS,
)}
> >
{isMobile && sidebarOpen && ( {isMobile && sidebarOpen && (
<button <button

View File

@ -27,6 +27,7 @@ import {
} from "@/lib/contacts/bulk-edit-fields" } from "@/lib/contacts/bulk-edit-fields"
import { import {
CONTACTS_FIELD_CLASS, CONTACTS_FIELD_CLASS,
CONTACTS_MENU_SURFACE_CLASS,
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS, CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
@ -107,7 +108,7 @@ export function ContactsBulkEditDialog({
<SelectTrigger className={CONTACTS_FIELD_CLASS}> <SelectTrigger className={CONTACTS_FIELD_CLASS}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
{CONTACT_BULK_EDIT_FIELDS.map((f) => ( {CONTACT_BULK_EDIT_FIELDS.map((f) => (
<SelectItem key={f.id} value={f.id}> <SelectItem key={f.id} value={f.id}>
{f.label} {f.label}

View File

@ -2,7 +2,7 @@
import { Menu, Search, X } from "lucide-react" import { Menu, Search, X } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { HeaderAccountActions } from "@/components/gmail/header-account-actions" import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import { import {
CONTACTS_ICON_BTN_CLASS, CONTACTS_ICON_BTN_CLASS,
CONTACTS_SEARCH_BAR_CLASS, CONTACTS_SEARCH_BAR_CLASS,

View File

@ -27,6 +27,7 @@ import { cn } from "@/lib/utils"
import { import {
CONTACTS_CREATE_BTN_CLASS, CONTACTS_CREATE_BTN_CLASS,
CONTACTS_FIELD_CLASS, CONTACTS_FIELD_CLASS,
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_NAV_ACTIVE_CLASS, CONTACTS_NAV_ACTIVE_CLASS,
CONTACTS_NAV_ICON_MUTED, CONTACTS_NAV_ICON_MUTED,
@ -34,12 +35,13 @@ import {
CONTACTS_CREATE_BTN_LABEL_CLASS, CONTACTS_CREATE_BTN_LABEL_CLASS,
CONTACTS_SIDEBAR_CLASS, CONTACTS_SIDEBAR_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes" import { CONTACTS_MENU_SURFACE_CLASS } from "@/lib/contacts-chrome-classes"
import { useContactsList } from "@/lib/contacts/use-contacts-list" import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery" import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection" import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import { ContactsPanelLogo } from "@/components/gmail/contacts/contacts-panel-logo"
import type { ContactsPageView } from "./contacts-app-shell" import type { ContactsPageView } from "./contacts-app-shell"
interface ContactsSidebarProps { interface ContactsSidebarProps {
@ -146,15 +148,10 @@ export function ContactsSidebar({
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
<button <ContactsPanelLogo
type="button"
onClick={onHome ?? (() => onNavigate("contacts"))} onClick={onHome ?? (() => onNavigate("contacts"))}
className="flex min-w-0 items-center gap-2 rounded-full px-1 py-0.5 transition-colors hover:bg-accent" titleClassName={cn("text-[22px] font-normal", CONTACTS_HEADING_TEXT)}
aria-label="Liste des contacts" />
>
<Users className={cn("h-6 w-6", CONTACTS_NAV_ICON_MUTED)} />
<span className={cn("text-[22px] font-normal", CONTACTS_MUTED_TEXT)}>Contacts</span>
</button>
</div> </div>
{/* Create button */} {/* Create button */}
@ -170,7 +167,11 @@ export function ContactsSidebar({
<ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} /> <ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("w-56", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}> <DropdownMenuContent
align="start"
data-contacts-menu-surface
className={cn("w-56", CONTACTS_MENU_SURFACE_CLASS)}
>
<DropdownMenuItem onClick={onCreateContact}> <DropdownMenuItem onClick={onCreateContact}>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Créer un contact Créer un contact

View File

@ -41,7 +41,7 @@ import {
CONTACTS_TABLE_TOOLBAR_CLASS, CONTACTS_TABLE_TOOLBAR_CLASS,
CONTACTS_TABLE_STICKY_HEAD_CLASS, CONTACTS_TABLE_STICKY_HEAD_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes" import { CONTACTS_MENU_SURFACE_CLASS } from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state" import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block" import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
@ -359,8 +359,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
data-contacts-menu-surface
className={cn( className={cn(
MAIL_SIDEBAR_MENU_SURFACE_CLASS, CONTACTS_MENU_SURFACE_CLASS,
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0", "flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
)} )}
> >
@ -410,7 +411,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-52", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}> <DropdownMenuContent
align="end"
data-contacts-menu-surface
className={cn("w-52", CONTACTS_MENU_SURFACE_CLASS)}
>
<DropdownMenuItem onClick={handleExportVcf}> <DropdownMenuItem onClick={handleExportVcf}>
Exporter au format vCard (.vcf) Exporter au format vCard (.vcf)
</DropdownMenuItem> </DropdownMenuItem>
@ -444,7 +449,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
<MoreVertical className="h-5 w-5" /> <MoreVertical className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-56 overflow-visible", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}> <DropdownMenuContent
align="end"
data-contacts-menu-surface
className={cn("w-56 overflow-visible", CONTACTS_MENU_SURFACE_CLASS)}
>
{selectionCount > 0 && ( {selectionCount > 0 && (
<> <>
<DropdownMenuSub> <DropdownMenuSub>
@ -453,8 +462,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
Ajouter / Retirer des libellés Ajouter / Retirer des libellés
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent <DropdownMenuSubContent
data-contacts-menu-surface
className={cn( className={cn(
MAIL_SIDEBAR_MENU_SURFACE_CLASS, CONTACTS_MENU_SURFACE_CLASS,
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0", "flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
)} )}
> >

View File

@ -13,6 +13,7 @@ import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar" import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { import {
CONTACTS_MENU_SURFACE_CLASS,
CONTACTS_HEADING_TEXT, CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_BANNER_CLASS, CONTACTS_PAGE_BANNER_CLASS,
@ -97,7 +98,11 @@ export function TrashView() {
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
align="end"
data-contacts-menu-surface
className={CONTACTS_MENU_SURFACE_CLASS}
>
<DropdownMenuItem onClick={() => restoreContact(contact.id)}> <DropdownMenuItem onClick={() => restoreContact(contact.id)}>
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Restaurer Restaurer

View File

@ -52,6 +52,7 @@ import { useNavStore } from "@/lib/stores/nav-store"
import { import {
CONTACTS_PANEL_ADD_TAG_BTN_CLASS, CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_CARD_CLASS, CONTACTS_PANEL_CARD_CLASS,
CONTACTS_MENU_SURFACE_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS, CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS, CONTACTS_PANEL_FLOATING_LABEL_CLASS,
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS, CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
@ -473,7 +474,11 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
Libellé Libellé
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-52 p-1" align="center"> <PopoverContent
data-contacts-menu-surface
className={cn("w-52 p-1", CONTACTS_MENU_SURFACE_CLASS)}
align="center"
>
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}> <p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
Libellés Libellés
</p> </p>
@ -941,7 +946,7 @@ function CompactSelect({
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}> <SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder ?? "Choisir..."} /> <SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
{options.map((opt) => ( {options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}

View File

@ -1,18 +1,26 @@
"use client" "use client"
import { Users } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { import {
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_TITLE_CLASS, CONTACTS_PANEL_TITLE_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
const CONTACTS_MARK_SRC = suitePublicAsset("/contacts-mark.svg")
type ContactsPanelLogoProps = { type ContactsPanelLogoProps = {
onClick: () => void onClick: () => void
className?: string className?: string
titleClassName?: string
markClassName?: string
} }
export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps) { export function ContactsPanelLogo({
onClick,
className,
titleClassName = CONTACTS_PANEL_TITLE_CLASS,
markClassName = "h-8 w-8",
}: ContactsPanelLogoProps) {
return ( return (
<button <button
type="button" type="button"
@ -23,8 +31,14 @@ export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps
)} )}
aria-label="Liste des contacts" aria-label="Liste des contacts"
> >
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} /> <img
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span> src={CONTACTS_MARK_SRC}
alt=""
className={cn("shrink-0 object-contain", markClassName)}
draggable={false}
aria-hidden
/>
<span className={cn("truncate", titleClassName)}>Contacts</span>
</button> </button>
) )
} }

View File

@ -62,14 +62,25 @@ export function EmailListAttachmentRow({
aria-hidden aria-hidden
> >
{attachments.map((att, idx) => ( {attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-m-${idx}`} att={att} /> <ListAttachmentChip
key={`${emailId}-m-${idx}`}
att={att}
messageId={emailId}
attachments={attachments}
attachmentIndex={idx}
/>
))} ))}
</div> </div>
)} )}
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"> <div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{collapsed && attachments.length > 1 ? ( {collapsed && attachments.length > 1 ? (
<> <>
<ListAttachmentChip att={attachments[0]!} /> <ListAttachmentChip
att={attachments[0]!}
messageId={emailId}
attachments={attachments}
attachmentIndex={0}
/>
<span <span
className="shrink-0 rounded-full border border-mail-list-chip-border bg-mail-list-chip-muted px-2.5 py-1 text-[13px] leading-snug text-muted-foreground" className="shrink-0 rounded-full border border-mail-list-chip-border bg-mail-list-chip-muted px-2.5 py-1 text-[13px] leading-snug text-muted-foreground"
title={othersTitle} title={othersTitle}
@ -79,7 +90,13 @@ export function EmailListAttachmentRow({
</> </>
) : ( ) : (
attachments.map((att, idx) => ( attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-v-${idx}`} att={att} /> <ListAttachmentChip
key={`${emailId}-v-${idx}`}
att={att}
messageId={emailId}
attachments={attachments}
attachmentIndex={idx}
/>
)) ))
)} )}
</div> </div>

View File

@ -1,11 +1,44 @@
"use client" "use client"
import { File, Image as ImageIcon } from "lucide-react" import { File, HardDrive, Image as ImageIcon } from "lucide-react"
import { toast } from "sonner"
import type { EmailAttachment } from "@/lib/email-data" import type { EmailAttachment } from "@/lib/email-data"
import {
mailAttachmentPreviewable,
openMailAttachmentsPreview,
} from "@/lib/mail/mail-attachment-preview"
export function ListAttachmentChip({
att,
messageId,
attachments,
attachmentIndex,
}: {
att: EmailAttachment
messageId: string
attachments: EmailAttachment[]
attachmentIndex: number
}) {
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!att.id) {
toast.message("Pièce jointe non disponible")
return
}
if (!mailAttachmentPreviewable(att)) {
toast.message("Aperçu non disponible")
return
}
openMailAttachmentsPreview(messageId, attachments, attachmentIndex)
}
export function ListAttachmentChip({ att }: { att: EmailAttachment }) {
return ( return (
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-mail-list-chip-border bg-transparent px-2.5 py-1 text-[13px] leading-snug text-mail-list-chip-text"> <button
type="button"
onClick={onClick}
className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-mail-list-chip-border bg-transparent px-2.5 py-1 text-[13px] leading-snug text-mail-list-chip-text hover:bg-mail-list-chip-muted"
>
{att.kind === "pdf" ? ( {att.kind === "pdf" ? (
<File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden /> <File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden />
) : att.kind === "image" ? ( ) : att.kind === "image" ? (
@ -18,6 +51,9 @@ export function ListAttachmentChip({ att }: { att: EmailAttachment }) {
<File className="size-4 shrink-0 fill-muted-foreground" strokeWidth={0} aria-hidden /> <File className="size-4 shrink-0 fill-muted-foreground" strokeWidth={0} aria-hidden />
)} )}
<span className="min-w-0 truncate">{att.name}</span> <span className="min-w-0 truncate">{att.name}</span>
</span> {att.drivePath ? (
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
) : null}
</button>
) )
} }

View File

@ -52,6 +52,7 @@ import {
buildSearchUrl, buildSearchUrl,
type SearchParams, type SearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import { searchParamsToMessageSearchFilter } from "@/lib/mail-search/search-filter"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { useMoveTargets } from "@/components/gmail/move-to-menu-items" import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index" import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
@ -192,15 +193,7 @@ export function useEmailListData({
const searchFilter = useMemo<MessageSearchFilter | null>(() => { const searchFilter = useMemo<MessageSearchFilter | null>(() => {
if (!isSearchMode || !searchParams) return null if (!isSearchMode || !searchParams) return null
return { return searchParamsToMessageSearchFilter(searchParams, accountId)
q: searchParams.q || undefined,
from: searchParams.from || undefined,
label: searchParams.in !== "all" ? searchParams.in : undefined,
account_id: accountId,
date_from: searchParams.after || undefined,
date_to: searchParams.before || undefined,
has_attachment: searchParams.has.includes("attachment") ? true : undefined,
}
}, [isSearchMode, searchParams, accountId]) }, [isSearchMode, searchParams, accountId])
const messagesQuery = useMessages( const messagesQuery = useMessages(

View File

@ -15,8 +15,9 @@ import {
senderInitial, senderInitial,
} from "@/lib/sender-display" } from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types" import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments" import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
import { attachmentsForEmailList } from "@/lib/attachment-display" import { useRecoverMissingMessageAttachments } from "@/lib/api/hooks/use-recover-missing-message-attachments"
import { resolvePreviewAttachments } from "@/lib/attachment-display"
import type { Email, EmailAttachment } from "@/lib/email-data" import type { Email, EmailAttachment } from "@/lib/email-data"
import { import {
mailFlagIsRead, mailFlagIsRead,
@ -69,6 +70,10 @@ import {
formatApiMessageBody, formatApiMessageBody,
plainTextBodyFallback, plainTextBodyFallback,
} from "@/components/gmail/email-view/email-view-messages" } from "@/components/gmail/email-view/email-view-messages"
import {
ConversationAttachmentsSection,
type ConversationAttachmentEntry,
} from "@/components/gmail/email-view/message-attachments"
function apiToLegacyEmail( function apiToLegacyEmail(
msg: ApiMessageSummary, msg: ApiMessageSummary,
@ -220,19 +225,69 @@ export function EmailView({
[email, fullMessage, threadMessages] [email, fullMessage, threadMessages]
) )
const { data: fetchedAttachments } = useMessageAttachments( const allThreadMessages = useMemo((): ApiMessageFull[] => {
email.id, const main: ApiMessageFull = fullMessage
email.has_attachments ? { ...email, ...fullMessage }
: { ...email }
return [...threadBefore, main, ...threadAfter]
}, [threadBefore, threadAfter, email, fullMessage])
// Thread API used to omit has_attachments; treat undefined as unknown (still fetch).
const attachmentMessageIds = useMemo(
() =>
allThreadMessages
.filter((m) => m.has_attachments !== false)
.map((m) => m.id),
[allThreadMessages]
) )
const { byId: attachmentsByMessageId, stateById: attachmentFetchStateById } =
useListMessageAttachments(attachmentMessageIds)
useRecoverMissingMessageAttachments(
allThreadMessages,
attachmentsByMessageId,
attachmentFetchStateById
)
const resolveMessageAttachments = useCallback(
(msg: Pick<ApiMessageSummary, "id" | "has_attachments">): EmailAttachment[] =>
resolvePreviewAttachments(
{ hasAttachment: msg.has_attachments },
attachmentsByMessageId.get(msg.id),
attachmentFetchStateById.get(msg.id) ?? "idle"
),
[attachmentsByMessageId, attachmentFetchStateById]
)
const mainMessageAttachments = useMemo( const mainMessageAttachments = useMemo(
(): EmailAttachment[] => () => resolveMessageAttachments(email),
attachmentsForEmailList({ [resolveMessageAttachments, email]
hasAttachment: email.has_attachments,
attachments: fetchedAttachments,
}),
[email.has_attachments, fetchedAttachments]
) )
const conversationAttachmentEntries = useMemo((): ConversationAttachmentEntry[] => {
return allThreadMessages
.map((msg) => {
const attachments = resolveMessageAttachments(msg)
if (attachments.length === 0) return null
const from = resolveMessageFrom(msg.from, {
selfEmails,
selfDisplayName,
})
return {
messageId: msg.id,
senderName: from.name,
attachments,
}
})
.filter((entry): entry is ConversationAttachmentEntry => entry !== null)
}, [
allThreadMessages,
resolveMessageAttachments,
selfEmails,
selfDisplayName,
])
const { composeWindows } = useComposeWindows() const { composeWindows } = useComposeWindows()
const { savedThreadReplyDrafts } = useComposeDrafts() const { savedThreadReplyDrafts } = useComposeDrafts()
const { openComposeWithInitial } = useComposeActions() const { openComposeWithInitial } = useComposeActions()
@ -382,6 +437,7 @@ export function EmailView({
selfEmails={selfEmails} selfEmails={selfEmails}
selfDisplayName={selfDisplayName} selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0} collapseQuotedReplies={otherThreadCount > 0}
attachments={resolveMessageAttachments(msg)}
/> />
</div> </div>
))} ))}
@ -419,10 +475,15 @@ export function EmailView({
selfEmails={selfEmails} selfEmails={selfEmails}
selfDisplayName={selfDisplayName} selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0} collapseQuotedReplies={otherThreadCount > 0}
attachments={resolveMessageAttachments(msg)}
/> />
</div> </div>
))} ))}
{otherThreadCount > 0 && conversationAttachmentEntries.length > 0 ? (
<ConversationAttachmentsSection entries={conversationAttachmentEntries} />
) : null}
{showReplyForwardBar ? ( {showReplyForwardBar ? (
<div <div
className={cn( className={cn(

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Star, Info } from "lucide-react" import { Star, Info, Paperclip } from "lucide-react"
import { useMessage } from "@/lib/api/hooks/use-mail-queries" import { useMessage } from "@/lib/api/hooks/use-mail-queries"
import { import {
Tooltip, Tooltip,
@ -86,6 +86,7 @@ export function ThreadPriorMessage({
selfEmails, selfEmails,
selfDisplayName, selfDisplayName,
collapseQuotedReplies = false, collapseQuotedReplies = false,
attachments = [],
}: { }: {
message: ApiMessageFull message: ApiMessageFull
isExpanded: boolean isExpanded: boolean
@ -96,6 +97,7 @@ export function ThreadPriorMessage({
selfEmails: string[] selfEmails: string[]
selfDisplayName?: string selfDisplayName?: string
collapseQuotedReplies?: boolean collapseQuotedReplies?: boolean
attachments?: EmailAttachment[]
}) { }) {
const [detailsOpen, setDetailsOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false)
const loadFull = isExpanded || detailsOpen const loadFull = isExpanded || detailsOpen
@ -137,6 +139,13 @@ export function ThreadPriorMessage({
message={message} message={message}
senderName={resolved.name} senderName={resolved.name}
senderEmail={resolved.email} senderEmail={resolved.email}
attachmentCount={
attachments.length > 0
? attachments.length
: message.has_attachments
? 1
: 0
}
onClick={onToggle} onClick={onToggle}
/> />
) )
@ -152,6 +161,7 @@ export function ThreadPriorMessage({
isSpam={isSpam} isSpam={isSpam}
isLast={false} isLast={false}
starred={mailFlagIsStarred(message.flags ?? [])} starred={mailFlagIsStarred(message.flags ?? [])}
attachments={attachments}
onCollapse={onToggle} onCollapse={onToggle}
onPrintConversation={onPrintConversation} onPrintConversation={onPrintConversation}
onReply={onReply} onReply={onReply}
@ -169,11 +179,13 @@ export function CollapsedMessage({
message, message,
senderName: senderNameProp, senderName: senderNameProp,
senderEmail: senderEmailProp, senderEmail: senderEmailProp,
attachmentCount = 0,
onClick, onClick,
}: { }: {
message: ApiMessageFull message: ApiMessageFull
senderName?: string senderName?: string
senderEmail?: string senderEmail?: string
attachmentCount?: number
onClick: () => void onClick: () => void
}) { }) {
const senderName = senderNameProp ?? message.from[0]?.name ?? "" const senderName = senderNameProp ?? message.from[0]?.name ?? ""
@ -206,6 +218,19 @@ export function CollapsedMessage({
<span className="truncate text-sm font-semibold text-foreground">{name}</span> <span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard> </ContactHoverCard>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{attachmentCount > 0 ? (
<span
className="flex items-center gap-0.5 text-xs text-muted-foreground"
title={
attachmentCount === 1
? "Une pièce jointe"
: `${attachmentCount} pièces jointes`
}
>
<Paperclip className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
{attachmentCount > 1 ? <span>{attachmentCount}</span> : null}
</span>
) : null}
<MailDateText <MailDateText
iso={message.date} iso={message.date}
variant="preview" variant="preview"
@ -302,7 +327,7 @@ export function ExpandedMessage({
</div> </div>
{attachments.length > 0 && ( {attachments.length > 0 && (
<MessageAttachmentsSection attachments={attachments} /> <MessageAttachmentsSection messageId={messageId} attachments={attachments} />
)} )}
</div> </div>
) )

View File

@ -0,0 +1,214 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { File, FileText, Image as ImageIcon, Play } from "lucide-react"
import type { EmailAttachment, EmailAttachmentKind } from "@/lib/email-data"
import { resolveAttachmentKind } from "@/lib/attachment-display"
import {
mailAttachmentCanThumb,
useMailAttachmentThumb,
} from "@/lib/api/hooks/use-mail-attachment-thumb"
import { cn } from "@/lib/utils"
function useInView(rootMargin = "120px") {
const ref = useRef<HTMLDivElement>(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 AttachmentKindFallback({ kind }: { kind: EmailAttachmentKind }) {
if (kind === "image") {
return <ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
}
if (kind === "pdf") {
return (
<div
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
aria-hidden
>
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
</div>
)
}
return <File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
}
function ThumbMedia({
url,
display,
onError,
}: {
url: string
display: "image" | "video"
onError: () => void
}) {
if (display === "video") {
return (
<>
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onLoadedData={(e) => {
const v = e.currentTarget
if (v.currentTime === 0) v.currentTime = 0.1
}}
onError={onError}
/>
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/15"
aria-hidden
>
<div className="flex size-9 items-center justify-center rounded-full bg-black/50 shadow-md">
<Play className="ml-0.5 size-4 fill-white text-white" />
</div>
</div>
</>
)
}
return (
<img
src={url}
alt=""
className="h-full w-full object-cover"
draggable={false}
onError={onError}
/>
)
}
export function MailAttachmentThumbnail({
attachment,
className,
}: {
attachment: EmailAttachment
className?: string
}) {
const kind = resolveAttachmentKind(attachment.name, attachment.kind)
const { ref, inView } = useInView()
const [failed, setFailed] = useState(false)
const retriedRef = useRef(false)
const queryClient = useQueryClient()
const canThumb = mailAttachmentCanThumb(attachment)
const showThumb = canThumb && !failed
const { data, isLoading } = useMailAttachmentThumb(attachment, inView && showThumb)
useEffect(() => {
retriedRef.current = false
setFailed(false)
}, [attachment.id, attachment.drivePath, attachment.name])
const handleError = () => {
if (!retriedRef.current && data?.url.startsWith("blob:")) {
retriedRef.current = true
void queryClient.invalidateQueries({
queryKey: ["mail", "attachment-thumb", attachment.id],
})
return
}
setFailed(true)
}
const showFallback = !showThumb || failed || (!data && !isLoading)
return (
<div
ref={ref}
className={cn(
"relative flex h-[132px] shrink-0 flex-col items-center justify-center overflow-hidden bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]",
className
)}
>
{showFallback ? (
<div className={cn("flex items-center justify-center", isLoading && showThumb && "opacity-40")}>
<AttachmentKindFallback kind={kind} />
</div>
) : null}
{showThumb && isLoading ? (
<div className="absolute inset-0 animate-pulse bg-muted/80" aria-hidden />
) : null}
{showThumb && data ? (
<div className="absolute inset-0 overflow-hidden bg-[#e8eaed] dark:bg-[#303134]">
<ThumbMedia url={data.url} display={data.display} onError={handleError} />
</div>
) : null}
</div>
)
}
export function MailAttachmentPillThumb({
attachment,
className,
}: {
attachment: EmailAttachment
className?: string
}) {
const kind = resolveAttachmentKind(attachment.name, attachment.kind)
const { ref, inView } = useInView("80px")
const [failed, setFailed] = useState(false)
const canThumb = mailAttachmentCanThumb(attachment) && kind === "image"
const { data, isLoading } = useMailAttachmentThumb(attachment, inView && canThumb && !failed)
if (!canThumb || failed) {
if (kind === "pdf") {
return <FileText className={cn("size-4 shrink-0 text-[#d93025]", className)} strokeWidth={1.5} aria-hidden />
}
if (kind === "image") {
return (
<ImageIcon
className={cn(
"size-4 shrink-0 text-muted-foreground [&_circle]:fill-none [&_path]:fill-none [&_path]:stroke-current [&_rect]:fill-current [&_rect]:opacity-[0.32]",
className
)}
strokeWidth={1.5}
aria-hidden
/>
)
}
return <File className={cn("size-4 shrink-0 text-[#5f6368]", className)} strokeWidth={1.5} aria-hidden />
}
return (
<span
ref={ref}
className={cn(
"relative flex size-6 shrink-0 overflow-hidden rounded-full bg-muted",
isLoading && "animate-pulse",
className
)}
>
{data ? (
<img
src={data.url}
alt=""
className="size-full object-cover"
draggable={false}
onError={() => setFailed(true)}
/>
) : null}
</span>
)
}

View File

@ -1,12 +1,17 @@
"use client" "use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { import {
Info, Info,
HardDrive, HardDrive,
File, File,
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
ExternalLink,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -20,24 +25,35 @@ import {
shouldUseAttachmentPillsInPreview, shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display" } from "@/lib/attachment-display"
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes" import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker"
import { useSaveMessageAttachmentsToDrive } from "@/lib/api/hooks/use-mail-drive-save"
import {
mailDriveFileHref,
mailDriveFolderHref,
mailDriveFolderLabel,
mailDriveFolderPathLabel,
mailDriveSaveErrorMessage,
mailDriveSaveSuccessMessage,
} from "@/lib/mail/mail-drive"
import {
mailAttachmentPreviewable,
openMailAttachmentsPreview,
} from "@/lib/mail/mail-attachment-preview"
import {
MailAttachmentPillThumb,
MailAttachmentThumbnail,
} from "@/components/gmail/email-view/mail-attachment-thumbnail"
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) { function MessageAttachmentCard({
attachment,
kind,
}: {
attachment: EmailAttachment
kind: EmailAttachmentKind
}) {
return ( return (
<> <>
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]"> <MailAttachmentThumbnail attachment={attachment} />
{kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<div
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
aria-hidden
>
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
</div>
) : (
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
)}
</div>
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5"> <div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
{kind === "pdf" ? ( {kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden /> <FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
@ -47,57 +63,276 @@ function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttach
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden /> <File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)} )}
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]"> <span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
{name} {attachment.name}
</span> </span>
</div> </div>
</> </>
) )
} }
function MessageAttachmentPill({ function DriveLocationBadge({ folderPath }: { folderPath: string }) {
name, const label = mailDriveFolderPathLabel(folderPath)
kind,
sizeBytes,
}: {
name: string
kind: EmailAttachmentKind
sizeBytes?: number
}) {
const tip = attachmentPreviewTooltip(name, sizeBytes)
return ( return (
<Tooltip> <Link
<TooltipTrigger asChild> href={mailDriveFolderHref(folderPath)}
<button className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md py-1 pl-1 pr-2 text-sm text-primary hover:bg-accent"
type="button" title={folderPath}
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" >
> <HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
{kind === "pdf" ? ( <span className="min-w-0 truncate">{label}</span>
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden /> <ExternalLink className="size-3.5 shrink-0 opacity-70" aria-hidden />
) : kind === "image" ? ( </Link>
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 truncate font-medium">{name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
</TooltipContent>
</Tooltip>
) )
} }
export function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) { export function MessageAttachmentsSection({
messageId,
attachments,
}: {
messageId: string
attachments: EmailAttachment[]
}) {
const n = attachments.length const n = attachments.length
const router = useRouter()
const [pickerOpen, setPickerOpen] = useState(false)
const saveAll = useSaveMessageAttachmentsToDrive(messageId)
const savedCount = attachments.filter((a) => a.drivePath).length
const allSaved = n > 0 && savedCount === n
const noneSaved = savedCount === 0
const uniqueSaveFolders = useMemo(() => {
const folders = new Set(
attachments
.map((a) => a.drivePath)
.filter(Boolean)
.map((p) => {
const idx = p!.lastIndexOf("/")
return idx > 0 ? p!.slice(0, idx) : p!
})
)
return [...folders]
}, [attachments])
if (n === 0) return null if (n === 0) return null
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes` const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments) const asPills = shouldUseAttachmentPillsInPreview(attachments)
const openPreview = (index: number) => {
if (!attachments.some((a) => a.id)) {
toast.message("Pièce jointe non disponible")
return
}
if (!mailAttachmentPreviewable(attachments[index]!)) {
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
return
}
openMailAttachmentsPreview(messageId, attachments, index)
}
const onSaveAll = async (folderPath: string) => {
try {
await saveAll.mutateAsync(folderPath)
setPickerOpen(false)
const folderLabel = mailDriveFolderPathLabel(folderPath)
toast.success(
n === 1
? mailDriveSaveSuccessMessage(folderPath)
: `${n} pièces jointes enregistrées dans ${folderLabel}`,
{
action: {
label: "Ouvrir le dossier",
onClick: () => router.push(mailDriveFolderHref(folderPath)),
},
}
)
} catch (err) {
toast.error(mailDriveSaveErrorMessage(err))
}
}
return ( return (
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4"> <>
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2"> <MailDriveFolderPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
title={n === 1 ? "Enregistrer dans UltiDrive" : `Enregistrer ${n} pièces jointes`}
description="Choisissez un dossier dans votre Drive."
confirmLabel="Enregistrer ici"
pending={saveAll.isPending}
onConfirm={onSaveAll}
/>
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
{allSaved && uniqueSaveFolders.length === 1 ? (
<DriveLocationBadge folderPath={uniqueSaveFolders[0]!} />
) : allSaved && uniqueSaveFolders.length > 1 ? (
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="size-[18px] shrink-0 text-primary" strokeWidth={1.5} aria-hidden />
Enregistré dans UltiDrive ({savedCount}/{n})
</span>
) : (
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent disabled:opacity-50"
aria-label="Ajouter à UltiDrive"
disabled={saveAll.isPending}
onClick={() => setPickerOpen(true)}
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
{noneSaved
? "Ajouter à UltiDrive"
: `Ajouter le reste à UltiDrive (${n - savedCount})`}
</button>
)}
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes"
>
{attachments.map((att, index) => {
const kind = resolveAttachmentKind(att.name, att.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
const previewable = mailAttachmentPreviewable(att)
if (asPills) {
return (
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(index)}
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MailAttachmentPillThumb attachment={att} />
<span className="min-w-0 truncate font-medium">{att.name}</span>
{att.drivePath ? (
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
{previewable ? "\nCliquer pour prévisualiser" : ""}
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
</TooltipContent>
</Tooltip>
</div>
)
}
return (
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(index)}
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard attachment={att} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
{previewable ? "\nCliquer pour prévisualiser" : ""}
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
</>
)
}
export type ConversationAttachmentEntry = {
messageId: string
senderName: string
attachments: EmailAttachment[]
}
export function ConversationAttachmentsSection({
entries,
}: {
entries: ConversationAttachmentEntry[]
}) {
const flat = useMemo(() => {
const items: {
messageId: string
senderName: string
attachments: EmailAttachment[]
index: number
attachment: EmailAttachment
}[] = []
for (const entry of entries) {
entry.attachments.forEach((attachment, index) => {
items.push({
messageId: entry.messageId,
senderName: entry.senderName,
attachments: entry.attachments,
index,
attachment,
})
})
}
return items
}, [entries])
const n = flat.length
if (n === 0) return null
const summary =
n === 1
? "Une pièce jointe dans cette conversation"
: `${n} pièces jointes dans cette conversation`
const asPills = shouldUseAttachmentPillsInPreview(flat.map((item) => item.attachment))
const openPreview = (messageId: string, attachments: EmailAttachment[], index: number) => {
if (!attachments.some((a) => a.id)) {
toast.message("Pièce jointe non disponible")
return
}
const att = attachments[index]
if (!att || !mailAttachmentPreviewable(att)) {
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
return
}
openMailAttachmentsPreview(messageId, attachments, index)
}
return (
<div className="mt-2 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground"> <div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{summary} {summary}
@ -120,14 +355,6 @@ export function MessageAttachmentsSection({ attachments }: { attachments: EmailA
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
aria-label="Ajouter à UltiDrive"
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
Ajouter à UltiDrive
</button>
</div> </div>
<div <div
@ -137,30 +364,70 @@ export function MessageAttachmentsSection({ attachments }: { attachments: EmailA
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]" : "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
} }
role="list" role="list"
aria-label="Pièces jointes" aria-label="Pièces jointes de la conversation"
> >
{attachments.map((att, index) => { {flat.map((item, flatIndex) => {
const kind = resolveAttachmentKind(att.name, att.kind) const kind = resolveAttachmentKind(item.attachment.name, item.attachment.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes) const previewable = mailAttachmentPreviewable(item.attachment)
const tip = [
item.senderName,
attachmentPreviewTooltip(item.attachment.name, item.attachment.sizeBytes),
previewable ? "Cliquer pour prévisualiser" : "",
item.attachment.drivePath ? mailDriveFolderLabel(item.attachment.drivePath) : "",
]
.filter(Boolean)
.join("\n")
if (asPills) { if (asPills) {
return ( return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem"> <div
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} /> key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
className="shrink-0"
role="listitem"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MailAttachmentPillThumb attachment={item.attachment} />
<span className="min-w-0 truncate font-medium">{item.attachment.name}</span>
{item.attachment.drivePath ? (
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
) : null}
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
>
{tip}
</TooltipContent>
</Tooltip>
</div> </div>
) )
} }
return ( return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem"> <div
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
className="shrink-0"
role="listitem"
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
> >
<MessageAttachmentCard name={att.name} kind={kind} /> <MessageAttachmentCard attachment={item.attachment} kind={kind} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}> <TooltipContent
side="bottom"
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
>
{tip} {tip}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@ -114,6 +114,7 @@ export function SandboxedContent({
csp: cspContent, csp: cspContent,
wrapperCss: themeCss, wrapperCss: themeCss,
plainTextFallback, plainTextFallback,
loadAppFont: !blockRemoteContent,
bodyTailCss: isDark bodyTailCss: isDark
? blockRemoteContent ? blockRemoteContent
? emailPreviewDarkTailOverrideCss() ? emailPreviewDarkTailOverrideCss()

View File

@ -1,235 +1,3 @@
"use client" "use client"
import { useState, useRef, useEffect } from "react" export { HeaderAccountActions } from "@/components/suite/header-account-actions"
import Link from "next/link"
import { toast } from "sonner"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { Pencil } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
import { Button } from "@/components/ui/button"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
const HEADER_ICON_BTN_CLASS = cn(
"rounded-full",
MAIL_ICON_BTN,
"hover:text-accent-foreground",
)
addCollection(mdiIcons)
type FavoriteApp = {
name: string
icon: string
href?: string
/** Logos sombres : blanc en dark via invert + hue-rotate. */
whiteLogoInDark?: boolean
}
const googleApps: FavoriteApp[] = [
{ name: "Compte", icon: "/compte-mark.svg" },
{ name: "Agenda", icon: "/agenda-mark.svg" },
{ name: "Photos", icon: "/photos-mark.svg" },
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
{ name: "UltiDrive", icon: "/ultidrive-mark.svg", href: "/drive" },
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
{ name: "Administration", icon: "/admin-mark.svg" },
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
{ name: "Mistral", icon: "/mistral-mark.svg" },
{ name: "Qwant", icon: "/qwant-mark.svg", whiteLogoInDark: true },
{ name: "Ground News", icon: "/ground-news-mark.svg", whiteLogoInDark: true },
]
const FAVORITE_TILE_CLASS =
"flex flex-col items-center gap-2 rounded-lg p-3 transition-colors hover:bg-accent"
function FavoriteAppTile({ app }: { app: FavoriteApp }) {
const content = (
<>
<div className="flex h-10 w-10 items-center justify-center">
<img
src={app.icon}
alt={app.name}
className={cn(
"h-10 w-10 object-contain",
app.whiteLogoInDark && "dark:invert dark:hue-rotate-180",
)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
target.parentElement!.innerHTML = `<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white">${app.name[0]}</div>`
}}
/>
</div>
<span className="w-full text-center text-xs text-muted-foreground">{app.name}</span>
</>
)
if (app.href) {
return (
<Link href={app.href} className={FAVORITE_TILE_CLASS}>
{content}
</Link>
)
}
return (
<button type="button" className={FAVORITE_TILE_CLASS}>
{content}
</button>
)
}
interface HeaderAccountActionsProps {
className?: string
/** When set, the settings button navigates here instead of opening quick settings. */
settingsHref?: string
}
export function HeaderAccountActions({
className,
settingsHref,
}: HeaderAccountActionsProps) {
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
const appsMenuRef = useRef<HTMLDivElement>(null)
const accountMenuRef = useRef<HTMLDivElement>(null)
const identity = useChromeIdentity()
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
useEffect(() => {
const notice = sessionStorage.getItem("ulti_account_notice")
if (notice === "same") {
sessionStorage.removeItem("ulti_account_notice")
toast.message("Vous utilisez déjà ce compte Ulti.")
}
}, [])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
appsMenuRef.current &&
!appsMenuRef.current.contains(event.target as Node)
) {
setAppsMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<div className={cn("flex shrink-0 items-center gap-1", className)}>
<Button
variant="ghost"
size="icon"
className={cn("hidden sm:inline-flex", HEADER_ICON_BTN_CLASS)}
aria-label="Aide"
>
<Icon
icon="mdi:help-circle-outline"
className="size-6 shrink-0"
aria-hidden
/>
</Button>
<Button
variant="ghost"
size="icon"
className={HEADER_ICON_BTN_CLASS}
aria-label="Réglages"
{...(settingsHref
? { asChild: true }
: { onClick: () => openQuickSettings(true) })}
>
{settingsHref ? (
<Link href={settingsHref}>
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
</Link>
) : (
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
)}
</Button>
<div className="relative hidden sm:block" ref={appsMenuRef}>
<Button
variant="ghost"
size="icon"
className={HEADER_ICON_BTN_CLASS}
aria-label="Applications"
onClick={() => {
setAppsMenuOpen(!appsMenuOpen)
setAccountMenuOpen(false)
}}
>
<Icon
icon="mdi:view-grid-outline"
className="size-6 shrink-0"
aria-hidden
/>
</Button>
{appsMenuOpen && (
<div
className={cn(
"absolute right-0 top-12 z-50 w-96 rounded-2xl",
MAIL_HEADER_DROPDOWN_CLASS,
)}
>
<div className="flex items-center justify-between border-b border-border p-4">
<span className="text-lg font-normal text-foreground">
Vos favoris
</span>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", HEADER_ICON_BTN_CLASS)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1 p-3">
{googleApps.map((app) => (
<FavoriteAppTile key={app.name} app={app} />
))}
</div>
</div>
)}
</div>
<div className="relative ml-2" ref={accountMenuRef}>
<Button
variant="ghost"
size="icon-lg"
className="size-11 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => {
setAccountMenuOpen(!accountMenuOpen)
setAppsMenuOpen(false)
}}
>
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="md"
/>
) : (
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
?
</span>
)}
</Button>
<AccountSwitcherDropdown
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
containerRef={accountMenuRef}
/>
</div>
</div>
)
}

View File

@ -4,7 +4,8 @@ import { Menu, Search } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { UltiMailLogo } from "@/components/ultimail-logo" import { UltiMailLogo } from "@/components/ultimail-logo"
import { MailSearchBar } from "@/components/gmail/mail-search-bar" import { MailSearchBar } from "@/components/gmail/mail-search-bar"
import { HeaderAccountActions } from "@/components/gmail/header-account-actions" import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface HeaderProps { interface HeaderProps {
@ -22,6 +23,8 @@ export function Header({
hideSearch = false, hideSearch = false,
onOpenMobileSearch, onOpenMobileSearch,
}: HeaderProps) { }: HeaderProps) {
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
return ( return (
<header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2"> <header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2">
{/* Rail width = page spacer so search left edge lines up with `<main>`. */} {/* Rail width = page spacer so search left edge lines up with `<main>`. */}
@ -70,7 +73,7 @@ export function Header({
<UltiMailLogo className="min-h-8 shrink-0 hidden sm:flex" /> <UltiMailLogo className="min-h-8 shrink-0 hidden sm:flex" />
</div> </div>
)} )}
<HeaderAccountActions /> <HeaderAccountActions onSettingsClick={() => openQuickSettings(true)} />
</div> </div>
</header> </header>
) )

View File

@ -0,0 +1,39 @@
"use client"
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useMailRoute } from "@/hooks/use-mail-route"
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
import type { ApiMessageSummary } from "@/lib/api/types"
import { mailDocumentTitle } from "@/lib/suite/page-metadata"
function subjectFromListCache(
queryClient: ReturnType<typeof useQueryClient>,
messageId: string
): string | null {
const entries = queryClient.getQueriesData<{ data?: ApiMessageSummary[] }>({
queryKey: ["messages"],
})
for (const [, payload] of entries) {
const hit = payload?.data?.find((m) => m.id === messageId)
if (hit?.subject?.trim()) return hit.subject
}
return null
}
/** Sync tab title: « Boîte mail - Ultimail » or « Sujet… - Ultimail » when a message is open. */
export function MailDocumentTitle() {
const { route } = useMailRoute()
const queryClient = useQueryClient()
const { data: message } = useMessage(route.mailId)
useEffect(() => {
const cachedSubject = route.mailId
? subjectFromListCache(queryClient, route.mailId)
: null
const subject = message?.subject ?? cachedSubject
document.title = mailDocumentTitle(route.mailId ? subject : null)
}, [route.mailId, message?.subject, queryClient])
return null
}

View File

@ -31,6 +31,7 @@ import {
import { import {
parseSearchParams, parseSearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
import { import {
buildQuickSearchParams, buildQuickSearchParams,
submitMailSearch, submitMailSearch,
@ -79,6 +80,8 @@ export function MailSearchBar({
toggleChipAttachment, toggleChipAttachment,
toggleChipLast7Days, toggleChipLast7Days,
toggleChipFromMe, toggleChipFromMe,
resetChips,
syncChipsFromParams,
reset, reset,
} = useMailSearchStore.getState() } = useMailSearchStore.getState()
@ -87,11 +90,14 @@ export function MailSearchBar({
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
useEffect(() => { useEffect(() => {
const q = currentSearchParams?.q ?? "" if (!isOnSearchPage) {
if (q && !inputValue) { resetChips()
setInputValue(q) return
} }
}, [currentSearchParams?.q]) const displayQuery = searchParamsToDisplayQuery(currentSearchParams)
setInputValue(displayQuery)
syncChipsFromParams(currentSearchParams, account?.email)
}, [isOnSearchPage, currentSearchParams, account?.email, setInputValue, resetChips, syncChipsFromParams])
const suggestions = useMemo<SearchSuggestion[]>(() => { const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim() || !searchContactResults?.length) return [] if (!inputValue.trim() || !searchContactResults?.length) return []
@ -131,6 +137,7 @@ export function MailSearchBar({
if (!Object.keys(params).length) return if (!Object.keys(params).length) return
submitMailSearch(router, params, { submitMailSearch(router, params, {
onAfter: () => { onAfter: () => {
setInputValue(q.trim())
setDropdownOpen(false) setDropdownOpen(false)
inputRef.current?.blur() inputRef.current?.blur()
}, },

View File

@ -1,6 +1,10 @@
"use client" "use client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
} from "@/lib/mail-chrome-classes"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { import type {
InboxSortMode, InboxSortMode,
@ -99,7 +103,7 @@ function ThemeModePicker({
compact?: boolean compact?: boolean
}) { }) {
return ( return (
<div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 sm:max-w-md")}> <div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 max-w-md lg:max-w-none")}>
{THEME_OPTIONS.map((opt) => ( {THEME_OPTIONS.map((opt) => (
<button <button
key={opt.id} key={opt.id}
@ -136,14 +140,23 @@ function SettingsSection({
action, action,
children, children,
className, className,
variant = "panel",
}: { }: {
title: string title: string
action?: React.ReactNode action?: React.ReactNode
children: React.ReactNode children: React.ReactNode
className?: string className?: string
variant?: "panel" | "page"
}) { }) {
return ( return (
<section className={cn("border-b border-border px-4 py-4", className)}> <section
className={cn(
"border-b border-border px-4 py-4",
variant === "page" &&
cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS),
className
)}
>
<SectionHeader title={title} action={action} /> <SectionHeader title={title} action={action} />
{children} {children}
</section> </section>
@ -186,11 +199,11 @@ export function MailSettingsFields({
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode) const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId) const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
const sectionClassName = variant === "page" ? "px-0 py-5" : undefined const isPage = variant === "page"
return ( const fields = (
<> <>
<SettingsSection title="Densité" className={sectionClassName}> <SettingsSection title="Densité" variant={variant}>
{DENSITY_OPTIONS.map((opt) => ( {DENSITY_OPTIONS.map((opt) => (
<QuickSettingsOption <QuickSettingsOption
key={opt.id} key={opt.id}
@ -205,7 +218,7 @@ export function MailSettingsFields({
<SettingsSection <SettingsSection
title="Thème" title="Thème"
className={sectionClassName} variant={variant}
action={ action={
variant === "panel" && onOpenThemeDialog ? ( variant === "panel" && onOpenThemeDialog ? (
<button <button
@ -230,7 +243,7 @@ export function MailSettingsFields({
<h3 className="mb-3 text-sm font-medium text-foreground"> <h3 className="mb-3 text-sm font-medium text-foreground">
Arrière-plan Arrière-plan
</h3> </h3>
<div className="grid grid-cols-3 gap-2 sm:max-w-lg sm:grid-cols-4"> <div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:max-w-lg lg:max-w-none">
{MAIL_BACKGROUND_PRESETS.map((preset) => ( {MAIL_BACKGROUND_PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
@ -266,10 +279,7 @@ export function MailSettingsFields({
)} )}
</SettingsSection> </SettingsSection>
<SettingsSection <SettingsSection title="Type de boîte de réception" variant={variant}>
title="Type de boîte de réception"
className={sectionClassName}
>
{INBOX_OPTIONS.map((opt) => ( {INBOX_OPTIONS.map((opt) => (
<QuickSettingsOption <QuickSettingsOption
key={opt.id} key={opt.id}
@ -282,7 +292,7 @@ export function MailSettingsFields({
))} ))}
</SettingsSection> </SettingsSection>
<SettingsSection title="Volet de lecture" className={sectionClassName}> <SettingsSection title="Volet de lecture" variant={variant}>
{READING_PANE_OPTIONS.map((opt) => ( {READING_PANE_OPTIONS.map((opt) => (
<QuickSettingsOption <QuickSettingsOption
key={opt.id} key={opt.id}
@ -298,7 +308,12 @@ export function MailSettingsFields({
))} ))}
</SettingsSection> </SettingsSection>
<section className={cn("px-4 py-4", variant === "page" && "px-0 py-5")}> <section
className={cn(
"border-b border-border px-4 py-4",
isPage && cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS)
)}
>
<SectionHeader title="Fils de discussion" /> <SectionHeader title="Fils de discussion" />
<QuickSettingsCheckbox <QuickSettingsCheckbox
label="Mode Conversation" label="Mode Conversation"
@ -309,4 +324,10 @@ export function MailSettingsFields({
</section> </section>
</> </>
) )
if (isPage) {
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
}
return fields
} }

View File

@ -19,7 +19,6 @@ export function MailToaster() {
} }
theme={resolvedTheme === "dark" ? "dark" : "light"} theme={resolvedTheme === "dark" ? "dark" : "light"}
richColors richColors
closeButton
/> />
) )
} }

View File

@ -67,7 +67,6 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
toggleChipLast7Days, toggleChipLast7Days,
toggleChipFromMe, toggleChipFromMe,
resetChips, resetChips,
reset,
} = useMailSearchStore.getState() } = useMailSearchStore.getState()
const [advancedMode, setAdvancedMode] = useState(false) const [advancedMode, setAdvancedMode] = useState(false)
@ -79,10 +78,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
setAdvancedMode(false) setAdvancedMode(false)
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 50)
} else { } else {
reset() resetChips()
setAdvancedMode(false) setAdvancedMode(false)
} }
}, [open, initialQuery, setInputValue, reset]) }, [open, initialQuery, setInputValue, resetChips])
const suggestions = useMemo<SearchSuggestion[]>(() => { const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim() || !searchContactResults?.length) return [] if (!inputValue.trim() || !searchContactResults?.length) return []

View File

@ -194,17 +194,20 @@ export function ThemeThumbnailIcon() {
function ThemeModePreviewFrame({ function ThemeModePreviewFrame({
children, children,
className, className,
...props
}: { }: {
children: React.ReactNode children: React.ReactNode
className?: string className?: string
}) { } & React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn( className={cn(
"flex w-full flex-col overflow-hidden rounded-md border border-border", "flex w-full flex-col overflow-hidden rounded-md border border-border",
className className
)} )}
style={{ backgroundColor: "#ffffff" }}
aria-hidden aria-hidden
{...props}
> >
{children} {children}
</div> </div>
@ -226,15 +229,18 @@ function MailChromePreview({
}) { }) {
return ( return (
<> <>
<div className={cn("h-2 shrink-0", headerBg)} /> <div className="h-2 shrink-0" style={{ backgroundColor: headerBg }} />
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<div className={cn("w-[24%] shrink-0", sidebarBg)} /> <div className="w-[24%] shrink-0" style={{ backgroundColor: sidebarBg }} />
<div className={cn("flex min-w-0 flex-1 flex-col p-0.5", listBg)}> <div
<div className={cn("h-px w-full", lineBg)} /> className="flex min-w-0 flex-1 flex-col p-0.5"
<div className={cn("mt-0.5 h-px w-3/4", lineBg)} /> style={{ backgroundColor: listBg }}
<div className={cn("mt-0.5 h-px w-1/2", lineBg)} /> >
<div className="h-px w-full" style={{ backgroundColor: lineBg }} />
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: lineBg }} />
<div className="mt-0.5 h-px w-1/2" style={{ backgroundColor: lineBg }} />
</div> </div>
<div className={cn("w-[30%] shrink-0", contentBg)} /> <div className="w-[30%] shrink-0" style={{ backgroundColor: contentBg }} />
</div> </div>
</> </>
) )
@ -242,13 +248,16 @@ function MailChromePreview({
export function ThemeLightPreview({ className }: { className?: string }) { export function ThemeLightPreview({ className }: { className?: string }) {
return ( return (
<ThemeModePreviewFrame className={cn("h-12", className)}> <ThemeModePreviewFrame
className={cn("h-12", className)}
data-mail-theme-preview="light"
>
<MailChromePreview <MailChromePreview
headerBg="bg-white" headerBg="#ffffff"
sidebarBg="bg-[#f1f3f4]" sidebarBg="#f1f3f4"
listBg="bg-white" listBg="#ffffff"
contentBg="bg-[#e8f0fe]" contentBg="#e8f0fe"
lineBg="bg-[#dadce0]" lineBg="#dadce0"
/> />
</ThemeModePreviewFrame> </ThemeModePreviewFrame>
) )
@ -256,13 +265,17 @@ export function ThemeLightPreview({ className }: { className?: string }) {
export function ThemeDarkPreview({ className }: { className?: string }) { export function ThemeDarkPreview({ className }: { className?: string }) {
return ( return (
<ThemeModePreviewFrame className={cn("h-12", className)}> <ThemeModePreviewFrame
className={cn("h-12", className)}
data-mail-theme-preview="dark"
style={{ backgroundColor: "#202124" }}
>
<MailChromePreview <MailChromePreview
headerBg="bg-[#202124]" headerBg="#202124"
sidebarBg="bg-[#3c4043]" sidebarBg="#3c4043"
listBg="bg-[#202124]" listBg="#202124"
contentBg="bg-[#394457]" contentBg="#394457"
lineBg="bg-[#5f6368]" lineBg="#5f6368"
/> />
</ThemeModePreviewFrame> </ThemeModePreviewFrame>
) )
@ -270,25 +283,35 @@ export function ThemeDarkPreview({ className }: { className?: string }) {
export function ThemeSystemPreview({ className }: { className?: string }) { export function ThemeSystemPreview({ className }: { className?: string }) {
return ( return (
<ThemeModePreviewFrame className={cn("h-12", className)}> <ThemeModePreviewFrame
className={cn("h-12", className)}
data-mail-theme-preview="system"
style={{ backgroundColor: "#ffffff" }}
>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<div className="flex w-1/2 min-w-0 flex-col"> <div className="flex w-1/2 min-w-0 flex-col">
<div className="h-2 shrink-0 bg-white" /> <div className="h-2 shrink-0" style={{ backgroundColor: "#ffffff" }} />
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<div className="w-[24%] shrink-0 bg-[#f1f3f4]" /> <div className="w-[24%] shrink-0" style={{ backgroundColor: "#f1f3f4" }} />
<div className="flex min-w-0 flex-1 flex-col bg-white p-0.5"> <div
<div className="h-px w-full bg-[#dadce0]" /> className="flex min-w-0 flex-1 flex-col p-0.5"
<div className="mt-0.5 h-px w-3/4 bg-[#dadce0]" /> style={{ backgroundColor: "#ffffff" }}
>
<div className="h-px w-full" style={{ backgroundColor: "#dadce0" }} />
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: "#dadce0" }} />
</div> </div>
</div> </div>
</div> </div>
<div className="flex w-1/2 min-w-0 flex-col"> <div className="flex w-1/2 min-w-0 flex-col">
<div className="h-2 shrink-0 bg-[#202124]" /> <div className="h-2 shrink-0" style={{ backgroundColor: "#202124" }} />
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<div className="w-[24%] shrink-0 bg-[#3c4043]" /> <div className="w-[24%] shrink-0" style={{ backgroundColor: "#3c4043" }} />
<div className="flex min-w-0 flex-1 flex-col bg-[#202124] p-0.5"> <div
<div className="h-px w-full bg-[#5f6368]" /> className="flex min-w-0 flex-1 flex-col p-0.5"
<div className="mt-0.5 h-px w-3/4 bg-[#5f6368]" /> style={{ backgroundColor: "#202124" }}
>
<div className="h-px w-full" style={{ backgroundColor: "#5f6368" }} />
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: "#5f6368" }} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,65 @@
"use client"
import { useState } from "react"
import { Check, Copy } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type { ApiTokenCreated } from "@/lib/api/types"
export function ApiTokenCreatedDialog({
created,
open,
onOpenChange,
}: {
created: ApiTokenCreated | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [copied, setCopied] = useState(false)
async function copyToken() {
if (!created?.token) return
await navigator.clipboard.writeText(created.token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Token créé</DialogTitle>
<DialogDescription>
Copiez ce secret maintenant. Il ne sera plus affiché par la suite.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<p className="text-sm font-medium">{created?.name}</p>
<div className="flex items-start gap-2">
<code className="flex-1 break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-xs">
{created?.token}
</code>
<Button type="button" variant="outline" size="icon" onClick={copyToken}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Préfixe visible : <span className="font-mono">{created?.token_prefix}</span>
</p>
</div>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
J&apos;ai copié le token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,164 @@
"use client"
import { useMemo, useState } from "react"
import { ChevronRight, Folder, Plus, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { useDriveList } from "@/lib/api/hooks/use-drive-queries"
import type { ApiTokenDriveScope } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
import { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree"
import { cn } from "@/lib/utils"
export function ApiTokenDriveScopeEditor({
scope,
onChange,
enabled,
className,
}: {
scope: ApiTokenDriveScope
onChange: (scope: ApiTokenDriveScope) => void
enabled: boolean
className?: string
}) {
const [browsePath, setBrowsePath] = useState("/")
const list = useDriveList(browsePath, 1, "", enabled && !scope.all_folders)
const folders = useMemo(
() => (list.data?.files ?? []).filter((f) => f.type === "directory"),
[list.data?.files]
)
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])
if (!enabled) return null
function addFolder(path: string) {
const normalized = normalizeDriveFolderPath(path)
if (scope.folder_paths.includes(normalized)) return
onChange({
all_folders: false,
folder_paths: [...scope.folder_paths, normalized],
})
}
function removeFolder(path: string) {
onChange({
all_folders: false,
folder_paths: scope.folder_paths.filter((p) => p !== path),
})
}
return (
<fieldset className={cn("space-y-3 rounded-md border border-border p-3", className)}>
<legend className="px-1 text-sm font-medium text-foreground">
Périmètre Drive dossiers
</legend>
<label className="flex items-start gap-2">
<Checkbox
checked={scope.all_folders}
onCheckedChange={(checked) =>
onChange({
all_folders: checked === true,
folder_paths: checked === true ? [] : scope.folder_paths,
})
}
className="mt-0.5"
/>
<span className="text-sm">
Tout le Drive
<span className="mt-0.5 block text-xs text-muted-foreground">
Accès à l&apos;intégralité de l&apos;arborescence.
</span>
</span>
</label>
{!scope.all_folders && (
<div className="space-y-3">
{scope.folder_paths.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Dossiers autorisés</Label>
<ul className="space-y-1">
{scope.folder_paths.map((path) => (
<li
key={path}
className="flex items-center justify-between gap-2 rounded-md border border-border px-2.5 py-1.5 text-sm"
>
<span className="truncate font-mono text-xs">{path}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => removeFolder(path)}
aria-label={`Retirer ${path}`}
>
<X className="size-3.5" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="space-y-2 rounded-md border border-dashed border-border p-2.5">
<Label className="text-xs text-muted-foreground">Ajouter un dossier</Label>
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<span key={crumb.path} className="inline-flex items-center gap-1">
{index > 0 && <ChevronRight className="size-3" />}
<button
type="button"
className="rounded px-1 hover:bg-muted hover:text-foreground"
onClick={() => setBrowsePath(crumb.path)}
>
{crumb.label}
</button>
</span>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => addFolder(browsePath)}
>
<Plus className="mr-1.5 size-3.5" />
Autoriser « {crumbs[crumbs.length - 1]?.label ?? "Mon Drive"} »
</Button>
{list.isLoading ? (
<p className="text-xs text-muted-foreground">Chargement</p>
) : folders.length > 0 ? (
<ul className="max-h-36 space-y-1 overflow-y-auto">
{folders.map((folder) => (
<li key={folder.path}>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted/50"
onClick={() => setBrowsePath(folder.path)}
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{displayFileName(folder.name)}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
</div>
)}
</fieldset>
)
}

View File

@ -0,0 +1,88 @@
"use client"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import type { ApiTokenMailScope } from "@/lib/api/types"
import { cn } from "@/lib/utils"
export function ApiTokenMailScopeEditor({
scope,
onChange,
enabled,
className,
}: {
scope: ApiTokenMailScope
onChange: (scope: ApiTokenMailScope) => void
enabled: boolean
className?: string
}) {
const { data: accounts = [], isLoading } = useMailAccounts()
if (!enabled) return null
function toggleAccount(accountId: string) {
const ids = scope.account_ids.includes(accountId)
? scope.account_ids.filter((id) => id !== accountId)
: [...scope.account_ids, accountId]
onChange({ all_accounts: false, account_ids: ids })
}
return (
<fieldset className={cn("space-y-3 rounded-md border border-border p-3", className)}>
<legend className="px-1 text-sm font-medium text-foreground">
Périmètre mail comptes
</legend>
<label className="flex items-start gap-2">
<Checkbox
checked={scope.all_accounts}
onCheckedChange={(checked) =>
onChange({
all_accounts: checked === true,
account_ids: checked === true ? [] : scope.account_ids,
})
}
className="mt-0.5"
/>
<span className="text-sm">
Tous les comptes mail
<span className="mt-0.5 block text-xs text-muted-foreground">
Le token pourra accéder à l&apos;ensemble des boîtes rattachées.
</span>
</span>
</label>
{!scope.all_accounts && (
<div className="space-y-2 pl-1">
<Label className="text-xs text-muted-foreground">Comptes autorisés</Label>
{isLoading ? (
<p className="text-sm text-muted-foreground">Chargement des comptes</p>
) : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun compte mail configuré.</p>
) : (
<ul className="space-y-1.5">
{accounts.map((account) => (
<li key={account.id}>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2.5 py-2 hover:bg-muted/40">
<Checkbox
checked={scope.account_ids.includes(account.id)}
onCheckedChange={() => toggleAccount(account.id)}
/>
<span className="min-w-0 text-sm">
<span className="block truncate font-medium">
{account.name || account.email}
</span>
<span className="block truncate text-xs text-muted-foreground">
{account.email}
</span>
</span>
</label>
</li>
))}
</ul>
)}
</div>
)}
</fieldset>
)
}

View File

@ -0,0 +1,163 @@
"use client"
import { useState } from "react"
import { ChevronRight } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Label } from "@/components/ui/label"
import {
API_TOKEN_PERMISSION_GROUPS,
API_TOKEN_PERMISSIONS,
type ApiTokenPermissionGrant,
type ApiTokenPermissionGroup,
} from "@/lib/mail-automation/api-token-permissions"
import { cn } from "@/lib/utils"
function updateGrant(
grants: ApiTokenPermissionGrant[],
resource: string,
patch: Partial<Pick<ApiTokenPermissionGrant, "read" | "write">>
): ApiTokenPermissionGrant[] {
return grants.map((grant) =>
grant.resource === resource ? { ...grant, ...patch } : grant
)
}
function countSelectedInGroup(
group: ApiTokenPermissionGroup,
grants: ApiTokenPermissionGrant[]
) {
const defs = API_TOKEN_PERMISSIONS.filter((def) => def.group === group)
return defs.filter((def) => {
const grant = grants.find((g) => g.resource === def.id)
return grant?.read || grant?.write
}).length
}
export function ApiTokenPermissionEditor({
grants,
onChange,
className,
}: {
grants: ApiTokenPermissionGrant[]
onChange: (grants: ApiTokenPermissionGrant[]) => void
className?: string
}) {
return (
<div className={cn("space-y-3", className)}>
<div>
<Label className="text-sm font-medium">Permissions</Label>
<p className="mt-0.5 text-xs text-muted-foreground">
Développez chaque domaine pour choisir lecture et écriture.
</p>
</div>
{API_TOKEN_PERMISSION_GROUPS.map((group) => (
<PermissionGroupSection
key={group.id}
group={group.id}
title={group.label}
description={group.description}
grants={grants}
onChange={onChange}
selectedCount={countSelectedInGroup(group.id, grants)}
/>
))}
</div>
)
}
function PermissionGroupSection({
group,
title,
description,
grants,
onChange,
selectedCount,
}: {
group: ApiTokenPermissionGroup
title: string
description: string
grants: ApiTokenPermissionGrant[]
onChange: (grants: ApiTokenPermissionGrant[]) => void
selectedCount: number
}) {
const [open, setOpen] = useState(false)
const defs = API_TOKEN_PERMISSIONS.filter((def) => def.group === group)
if (defs.length === 0) return null
return (
<Collapsible open={open} onOpenChange={setOpen}>
<fieldset className="overflow-hidden rounded-md border border-border">
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted/40">
<ChevronRight
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{title}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{selectedCount > 0 ? `${selectedCount} active(s)` : "Aucune"}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border">
<div className="grid grid-cols-[1fr_4.5rem_4.5rem] gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Permission</span>
<span className="text-center">Lecture</span>
<span className="text-center">Écriture</span>
</div>
{defs.map((def) => {
const grant = grants.find((g) => g.resource === def.id) ?? {
resource: def.id,
read: false,
write: false,
}
return (
<div
key={def.id}
className="grid grid-cols-[1fr_4.5rem_4.5rem] items-start gap-2 border-b border-border px-3 py-2.5 last:border-b-0"
>
<div className="min-w-0 pr-2">
<Label className="text-sm font-medium">{def.label}</Label>
<p className="mt-0.5 text-xs text-muted-foreground">{def.description}</p>
</div>
<div className="flex justify-center pt-0.5">
{def.supportsRead ? (
<Checkbox
checked={grant.read}
onCheckedChange={(checked) =>
onChange(updateGrant(grants, def.id, { read: checked === true }))
}
aria-label={`${def.label} — lecture`}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<div className="flex justify-center pt-0.5">
{def.supportsWrite ? (
<Checkbox
checked={grant.write}
onCheckedChange={(checked) =>
onChange(updateGrant(grants, def.id, { write: checked === true }))
}
aria-label={`${def.label} — écriture`}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
)
})}
</div>
</CollapsibleContent>
</fieldset>
</Collapsible>
)
}

View File

@ -0,0 +1,245 @@
"use client"
import { useState } from "react"
import { ExternalLink, KeyRound, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ApiTokenCreatedDialog } from "@/components/gmail/settings/automation/api-token-created-dialog"
import { ApiTokenDriveScopeEditor } from "@/components/gmail/settings/automation/api-token-drive-scope-editor"
import { ApiTokenMailScopeEditor } from "@/components/gmail/settings/automation/api-token-mail-scope-editor"
import { ApiTokenPermissionEditor } from "@/components/gmail/settings/automation/api-token-permission-editor"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import {
useApiTokens,
useCreateApiToken,
useRevokeApiToken,
} from "@/lib/api/hooks/use-api-tokens"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { ApiTokenCreated } from "@/lib/api/types"
import {
defaultDriveScope,
defaultMailScope,
emptyPermissionGrants,
hasAnyPermission,
hasDrivePermissions,
hasMailPermissions,
summarizePermissions,
type ApiTokenPermissionGrant,
} from "@/lib/mail-automation/api-token-permissions"
function formatDate(value?: string) {
if (!value) return "—"
try {
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value))
} catch {
return value
}
}
export function ApiTokensPanel() {
const { ready, authenticated } = useAuthReady()
const { data: tokens = [], isFetching, isError, refetch, isPending } = useApiTokens()
const createToken = useCreateApiToken()
const revokeToken = useRevokeApiToken()
const [name, setName] = useState("")
const [permissions, setPermissions] = useState<ApiTokenPermissionGrant[]>(emptyPermissionGrants)
const [mailScope, setMailScope] = useState(defaultMailScope)
const [driveScope, setDriveScope] = useState(defaultDriveScope)
const [created, setCreated] = useState<ApiTokenCreated | null>(null)
const [createdOpen, setCreatedOpen] = useState(false)
const showInitialLoad = ready && authenticated && isPending && tokens.length === 0
const mailScopeEnabled = hasMailPermissions(permissions)
const driveScopeEnabled = hasDrivePermissions(permissions)
const canSubmit =
name.trim().length > 0 &&
hasAnyPermission(permissions) &&
(!mailScopeEnabled || mailScope.all_accounts || mailScope.account_ids.length > 0) &&
(!driveScopeEnabled || driveScope.all_folders || driveScope.folder_paths.length > 0)
async function handleCreate() {
const result = await createToken.mutateAsync({
name: name.trim(),
permissions: permissions.filter((g) => g.read || g.write),
mail_scope: mailScopeEnabled ? mailScope : defaultMailScope(),
drive_scope: driveScopeEnabled ? driveScope : defaultDriveScope(),
})
setCreated(result)
setCreatedOpen(true)
setName("")
setPermissions(emptyPermissionGrants())
setMailScope(defaultMailScope())
setDriveScope(defaultDriveScope())
}
return (
<div className="space-y-4">
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<AutomationTabMasonry columns={2}>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Nouveau token</CardTitle>
<CardDescription>
Jetons fine-grained pour agents IA, scripts et intégrations externes. Choisissez
les permissions, puis restreignez le périmètre mail et Drive si nécessaire.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-xs text-muted-foreground">
Documentation API interactive :{" "}
<a
href="/api/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[#1a73e8] hover:underline"
>
/api/docs
<ExternalLink className="size-3" />
</a>
</p>
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Agent tri boîte support"
/>
</div>
<ApiTokenPermissionEditor grants={permissions} onChange={setPermissions} />
<div className="grid gap-4 lg:grid-cols-2">
<ApiTokenMailScopeEditor
scope={mailScope}
onChange={setMailScope}
enabled={mailScopeEnabled}
/>
<ApiTokenDriveScopeEditor
scope={driveScope}
onChange={setDriveScope}
enabled={driveScopeEnabled}
/>
</div>
<Button
type="button"
disabled={!canSubmit || createToken.isPending}
className="w-full sm:w-auto"
onClick={handleCreate}
>
<Plus className="mr-1.5 size-3.5" />
Générer le token
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-4" />
Tokens actifs
</CardTitle>
<CardDescription>
Révoquez un jeton compromis ou inutilisé à tout moment.
</CardDescription>
</CardHeader>
<CardContent>
{showInitialLoad ? (
<p className="text-sm text-muted-foreground">Chargement</p>
) : tokens.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun token API pour le moment.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{tokens.map((token) => {
const summary = summarizePermissions(token.permissions)
return (
<li
key={token.id}
className="flex items-start justify-between gap-3 px-3 py-3"
>
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">{token.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{token.token_prefix}
</p>
{summary.length > 0 && (
<ul className="text-xs text-muted-foreground">
{summary.slice(0, 4).map((line) => (
<li key={line}>{line}</li>
))}
{summary.length > 4 && (
<li>+ {summary.length - 4} autre(s) permission(s)</li>
)}
</ul>
)}
<p className="text-xs text-muted-foreground">
Créé {formatDate(token.created_at)}
{token.last_used_at
? ` · Dernière utilisation ${formatDate(token.last_used_at)}`
: ""}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={revokeToken.isPending}
onClick={() => revokeToken.mutate(token.id)}
aria-label={`Révoquer ${token.name}`}
>
<Trash2 className="size-4" />
</Button>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3">
<CardTitle className="text-base">Bonnes pratiques</CardTitle>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Limitez chaque token au périmètre strict nécessaire (principe du moindre privilège).
</p>
<p>
Le secret n&apos;est affiché qu&apos;une fois à la création stockez-le dans un
gestionnaire de secrets, jamais dans le code source.
</p>
<p>
La permission « Super admin Tokens API » permet aussi de gérer d&apos;autres
tokens via l&apos;API réservez-la aux agents d&apos;administration de confiance.
</p>
<p>
Préférez des tokens dédiés par agent ou intégration pour faciliter la révocation.
</p>
</div>
</CardContent>
</Card>
</AutomationTabMasonry>
<ApiTokenCreatedDialog
created={created}
open={createdOpen}
onOpenChange={setCreatedOpen}
/>
</div>
)
}

View File

@ -0,0 +1,40 @@
"use client"
import { Children, type ReactNode } from "react"
import { cn } from "@/lib/utils"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
} from "@/lib/mail-chrome-classes"
export function AutomationTabMasonry({
columns,
children,
className,
}: {
columns: 1 | 2
children: ReactNode
className?: string
}) {
if (columns === 1) {
return <div className={cn("space-y-4", className)}>{children}</div>
}
const items = Children.toArray(children).filter(Boolean)
return (
<div
className={cn(
"space-y-4 lg:space-y-0",
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
className
)}
>
{items.map((child, index) => (
<div key={index} className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
{child}
</div>
))}
</div>
)
}

View File

@ -18,6 +18,7 @@ import {
} from "@/lib/api/hooks/use-contact-discovery" } from "@/lib/api/hooks/use-contact-discovery"
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types" import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input" import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { import {
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS, CONTACTS_PRIMARY_BTN_CLASS,
@ -92,7 +93,7 @@ export function LLMProvidersPanel() {
} }
return ( return (
<div className="max-w-2xl space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-base font-medium">Fournisseurs LLM</h3> <h3 className="text-base font-medium">Fournisseurs LLM</h3>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}> <p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
@ -100,130 +101,133 @@ export function LLMProvidersPanel() {
</p> </p>
</div> </div>
{draft.providers.map((provider, index) => ( <AutomationTabMasonry columns={2}>
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4"> {draft.providers.map((provider, index) => (
<div className="flex items-center justify-between"> <div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span> <div className="flex items-center justify-between">
<Button <span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => removeProvider(index)} size="icon"
aria-label="Supprimer" onClick={() => removeProvider(index)}
> aria-label="Supprimer"
<Trash2 className="h-4 w-4" /> >
</Button> <Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">URL de base</Label>
<Input
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder="sk-…"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle par défaut</Label>
<LLMModelSuggestInput
className="mt-1"
baseUrl={provider.base_url}
apiKey={provider.api_key}
value={provider.default_model}
onChange={(default_model) => updateProvider(index, { default_model })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
</div> </div>
))}
<div className="space-y-3 rounded-lg border border-border p-4">
<h4 className="text-sm font-medium">Découverte de contacts</h4>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div>
<Label className="text-xs">Nom</Label> <Label className="text-xs">Fournisseur par défaut</Label>
<Input <Select
className="mt-1 h-9" value={draft.default_provider_id}
value={provider.name} onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
onChange={(e) => updateProvider(index, { name: e.target.value })} >
placeholder="OpenAI" <SelectTrigger className="mt-1 h-9">
/> <SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Modèle pour l&apos;enrichissement</Label>
<Select
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
onValueChange={(v) =>
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Même que défaut" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label className="text-xs">URL de base</Label> <Label className="text-xs">Modèle LLM</Label>
<Input <Input
className="mt-1 h-9" className="mt-1 h-9"
value={provider.base_url} value={draft.contact_discovery_model ?? ""}
onChange={(e) => updateProvider(index, { base_url: e.target.value })} onChange={(e) =>
placeholder="https://api.openai.com/v1" setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
/> }
</div> placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
<div className="sm:col-span-2">
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder="sk-…"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle par défaut</Label>
<LLMModelSuggestInput
className="mt-1"
baseUrl={provider.base_url}
apiKey={provider.api_key}
value={provider.default_model}
onChange={(default_model) => updateProvider(index, { default_model })}
placeholder="gpt-4o-mini"
/> />
</div> </div>
</div> </div>
</div> </div>
))} </AutomationTabMasonry>
<Button variant="outline" onClick={addProvider}> <div className="flex flex-wrap gap-2">
<Plus className="mr-2 h-4 w-4" /> <Button variant="outline" onClick={addProvider}>
Ajouter un fournisseur <Plus className="mr-2 h-4 w-4" />
</Button> Ajouter un fournisseur
</Button>
<div className="space-y-3 rounded-lg border border-border p-4"> <Button
<h4 className="text-sm font-medium">Découverte de contacts</h4> onClick={handleSave}
<div className="grid gap-3 sm:grid-cols-2"> disabled={updateSettings.isPending}
<div> className={CONTACTS_PRIMARY_BTN_CLASS}
<Label className="text-xs">Fournisseur par défaut</Label> >
<Select {updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
value={draft.default_provider_id} </Button>
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Modèle pour l&apos;enrichissement</Label>
<Select
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
onValueChange={(v) =>
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Même que défaut" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle LLM</Label>
<Input
className="mt-1 h-9"
value={draft.contact_discovery_model ?? ""}
onChange={(e) =>
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
}
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
/>
</div>
</div>
</div> </div>
<Button
onClick={handleSave}
disabled={updateSettings.isPending}
className={CONTACTS_PRIMARY_BTN_CLASS}
>
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
</Button>
</div> </div>
) )
} }

View File

@ -72,7 +72,7 @@ export function SearchProvidersPanel() {
} }
return ( return (
<div className="max-w-2xl space-y-6"> <div className="space-y-6">
<div> <div>
<h3 className="text-base font-medium">Fournisseurs de recherche</h3> <h3 className="text-base font-medium">Fournisseurs de recherche</h3>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}> <p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>

View File

@ -0,0 +1,106 @@
"use client"
import { useCallback, useState } from "react"
import { Check, Copy } from "lucide-react"
import { toast } from "sonner"
import {
WEBHOOK_TEMPLATE_VARIABLE_GROUPS,
type WebhookTemplateVariable,
} from "@/lib/mail-automation/webhook-template-variables"
import { cn } from "@/lib/utils"
function VariableChip({
variable,
copied,
onCopy,
}: {
variable: WebhookTemplateVariable
copied: boolean
onCopy: (token: string) => void
}) {
return (
<button
type="button"
title={`${variable.label}${variable.description}`}
aria-label={`Copier ${variable.token}`}
onClick={() => onCopy(variable.token)}
className={cn(
"inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-[11px] leading-snug transition-colors",
"border-border bg-muted/40 text-foreground hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
copied && "border-primary/40 bg-primary/10 text-primary"
)}
>
{copied ? (
<Check className="size-3 shrink-0" aria-hidden />
) : (
<Copy className="size-3 shrink-0 opacity-60" aria-hidden />
)}
<span className="truncate">{variable.token}</span>
</button>
)
}
export function WebhookTemplateVariablesPanel() {
const [copiedToken, setCopiedToken] = useState<string | null>(null)
const copyToken = useCallback(async (token: string) => {
try {
await navigator.clipboard.writeText(token)
setCopiedToken(token)
toast.success(`${token} copié`)
window.setTimeout(() => {
setCopiedToken((current) => (current === token ? null : current))
}, 1500)
} catch {
toast.error("Impossible de copier la variable")
}
}, [])
return (
<div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
<div className="space-y-1">
<p className="text-sm font-medium">Variables du template</p>
<p className="text-xs text-muted-foreground">
Insérez ces variables dans votre JSON{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">body_template</code>.
Cliquez sur une puce pour la copier dans le presse-papiers.
</p>
</div>
<div className="space-y-4">
{WEBHOOK_TEMPLATE_VARIABLE_GROUPS.map((group) => (
<section key={group.id} className="space-y-2">
<div>
<h3 className="text-xs font-medium">{group.label}</h3>
<p className="text-[11px] text-muted-foreground">{group.description}</p>
</div>
<ul className="space-y-2">
{group.variables.map((variable) => (
<li
key={variable.token}
className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:gap-3"
>
<VariableChip
variable={variable}
copied={copiedToken === variable.token}
onCopy={copyToken}
/>
<div className="min-w-0 flex-1 text-[11px] leading-snug">
<span className="font-medium text-foreground">{variable.label}</span>
<span className="text-muted-foreground"> {variable.description}</span>
{variable.example ? (
<span className="mt-0.5 block font-mono text-[10px] text-muted-foreground/80">
Ex. {variable.example}
</span>
) : null}
</div>
</li>
))}
</ul>
</section>
))}
</div>
</div>
)
}

View File

@ -12,6 +12,8 @@ import {
} from "@/lib/api/hooks/use-mail-automation-queries" } from "@/lib/api/hooks/use-mail-automation-queries"
import { useAuthReady } from "@/lib/api/use-auth-ready" import { useAuthReady } from "@/lib/api/use-auth-ready"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner" import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { WebhookTemplateVariablesPanel } from "@/components/gmail/settings/automation/webhook-template-variables-panel"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
export function WebhooksPanel() { export function WebhooksPanel() {
const { ready, authenticated } = useAuthReady() const { ready, authenticated } = useAuthReady()
@ -28,57 +30,62 @@ export function WebhooksPanel() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-2 rounded-lg border border-border p-4"> <AutomationTabMasonry columns={2}>
<Label className="text-xs">Nouveau webhook</Label> <WebhookTemplateVariablesPanel />
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} /> <div className="space-y-2 rounded-lg border border-border p-4">
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} /> <Label className="text-xs">Nouveau webhook</Label>
<textarea <Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs" <Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
value={template} <textarea
onChange={(e) => setTemplate(e.target.value)} className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
placeholder="body_template JSON" value={template}
/> onChange={(e) => setTemplate(e.target.value)}
<Button placeholder="body_template JSON"
type="button" />
disabled={!name.trim() || !url.trim() || createWebhook.isPending} <Button
onClick={() => type="button"
createWebhook.mutate({ disabled={!name.trim() || !url.trim() || createWebhook.isPending}
name: name.trim(), onClick={() =>
url: url.trim(), createWebhook.mutate({
method: "POST", name: name.trim(),
body_template: template, url: url.trim(),
}) method: "POST",
} body_template: template,
> })
Créer le webhook }
</Button> >
</div> Créer le webhook
</Button>
</div>
{showInitialLoad ? null : webhooks.length === 0 ? ( {showInitialLoad ? (
<p className="text-sm text-muted-foreground">Aucun webhook.</p> <p className="text-sm text-muted-foreground">Chargement</p>
) : ( ) : webhooks.length === 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border"> <p className="text-sm text-muted-foreground">Aucun webhook.</p>
{webhooks.map((hook) => ( ) : (
<li <ul className="divide-y divide-border rounded-lg border border-border">
key={hook.id} {webhooks.map((hook) => (
className="flex items-start justify-between gap-2 px-3 py-3" <li
> key={hook.id}
<div className="min-w-0"> className="flex items-start justify-between gap-2 px-3 py-3"
<p className="text-sm font-medium">{hook.name}</p>
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => deleteWebhook.mutate(hook.id)}
> >
<Trash2 className="size-4" /> <div className="min-w-0">
</Button> <p className="text-sm font-medium">{hook.name}</p>
</li> <p className="truncate text-xs text-muted-foreground">{hook.url}</p>
))} </div>
</ul> <Button
)} type="button"
variant="ghost"
size="icon"
onClick={() => deleteWebhook.mutate(hook.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</AutomationTabMasonry>
</div> </div>
) )
} }

View File

@ -0,0 +1,33 @@
"use client"
import { UltiMailLogo } from "@/components/ultimail-logo"
import { MailSettingsSearchBar } from "@/components/gmail/settings/mail-settings-search-bar"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
const SETTINGS_HREF = "/mail/settings"
export function MailSettingsHeader() {
return (
<header
data-mail-settings-chrome-header
className="flex h-16 w-full shrink-0 items-center gap-0 bg-app-canvas pr-4 sm:gap-2"
>
<div className="hidden h-full w-64 shrink-0 items-center pl-4 md:flex lg:w-72">
<UltiMailLogo className="min-h-8 shrink-0" />
</div>
<div className="flex shrink-0 items-center pl-2 md:hidden">
<UltiMailLogo variant="mark" className="h-8 w-8" />
</div>
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1">
<MailSettingsSearchBar className="w-full max-w-3xl" />
</div>
<HeaderAccountActions
className="ml-auto shrink-0 pl-2 sm:pl-4"
settingsHref={SETTINGS_HREF}
/>
</header>
)
}

View File

@ -2,40 +2,34 @@
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
isMailSettingsNavActive, isMailSettingsNavActive,
isMailSettingsWideLayoutPath,
MAIL_SETTINGS_NAV, MAIL_SETTINGS_NAV,
} from "@/lib/mail-settings/settings-nav" } from "@/lib/mail-settings/settings-nav"
import {
mailNavRowClass,
MAIL_SETTINGS_MAIN_CARD_CLASS,
MAIL_SETTINGS_MAIN_INSET_CLASS,
} from "@/lib/mail-chrome-classes"
import { MailSettingsHeader } from "@/components/gmail/settings/mail-settings-header"
export function MailSettingsLayout({ children }: { children: React.ReactNode }) { export function MailSettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
return ( return (
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-background"> <div
<header className="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-6"> data-mail-settings-app
<div className="mx-auto flex max-w-6xl items-center gap-3"> className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
<Link >
href="/mail/inbox" <MailSettingsHeader />
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Retour à la boîte de réception"
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-normal text-foreground">
Paramètres Ultimail
</h1>
<p className="text-sm text-muted-foreground">
Configuration du compte, de l&apos;affichage et des automatisations
</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row"> <div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border bg-muted/20 p-3 md:block lg:w-72"> <aside
data-mail-settings-sidebar
className="hidden w-64 shrink-0 overflow-y-auto bg-app-canvas p-3 md:block lg:w-72"
>
<nav className="space-y-1" aria-label="Sections des paramètres"> <nav className="space-y-1" aria-label="Sections des paramètres">
{MAIL_SETTINGS_NAV.map((item) => { {MAIL_SETTINGS_NAV.map((item) => {
const active = isMailSettingsNavActive(pathname, item) const active = isMailSettingsNavActive(pathname, item)
@ -44,17 +38,30 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
aria-current={active ? "page" : undefined}
className={cn( className={cn(
"flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active
? "bg-accent text-accent-foreground" ? "bg-mail-nav-selected"
: "text-foreground hover:bg-accent/50" : "hover:bg-mail-nav-hover"
)} )}
> >
<Icon className="mt-0.5 size-4 shrink-0 opacity-70" /> <Icon
<span className="min-w-0"> className={cn(
<span className="block text-sm font-medium">{item.label}</span> "mt-0.5 size-4 shrink-0 opacity-70",
<span className="block text-xs text-muted-foreground"> active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-medium",
active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
>
{item.label}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{item.description} {item.description}
</span> </span>
</span> </span>
@ -64,41 +71,50 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
</nav> </nav>
</aside> </aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
<nav <div data-mail-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
className="shrink-0 border-b border-border bg-muted/20 px-2 py-2 md:hidden" <nav
aria-label="Sections des paramètres" className="shrink-0 border-b border-border px-2 py-2 md:hidden"
> aria-label="Sections des paramètres"
<div className="flex gap-1 overflow-x-auto"> >
{MAIL_SETTINGS_NAV.map((item) => { <div className="flex gap-1 overflow-x-auto">
const active = isMailSettingsNavActive(pathname, item) {MAIL_SETTINGS_NAV.map((item) => {
const Icon = item.icon const active = isMailSettingsNavActive(pathname, item)
return ( const Icon = item.icon
<Link return (
key={item.id} <Link
href={item.href} key={item.id}
aria-label={item.label} href={item.href}
aria-current={active ? "page" : undefined} aria-label={item.label}
className={cn( aria-current={active ? "page" : undefined}
"flex shrink-0 items-center rounded-lg transition-colors", className={cn(
active "flex shrink-0 items-center rounded-lg",
? "gap-2 bg-accent px-3 py-2 text-accent-foreground" active
: "size-9 justify-center text-foreground hover:bg-accent/50" ? cn("gap-2 px-3 py-2", mailNavRowClass({ isSelected: true }))
)} : cn("size-9 justify-center", mailNavRowClass({ isSelected: false }))
> )}
<Icon className="size-4 shrink-0 opacity-70" /> >
{active ? ( <Icon className="size-4 shrink-0 opacity-70" />
<span className="text-sm font-medium">{item.label}</span> {active ? (
) : null} <span className="text-sm font-medium">{item.label}</span>
</Link> ) : null}
) </Link>
})} )
</div> })}
</nav> </div>
</nav>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8"> <main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<div className="mx-auto max-w-3xl">{children}</div> <div
</main> className={cn(
"mx-auto w-full max-w-3xl",
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
)}
>
{children}
</div>
</main>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,205 @@
"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react"
import { useRouter } from "next/navigation"
import { Search, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
import { DRIVE_SEARCH_INPUT_WRAP_CLASS } from "@/lib/drive/drive-chrome-classes"
import { searchMailSettings } from "@/lib/mail-settings/search-settings"
import type { MailSettingsSearchEntry } from "@/lib/mail-settings/settings-search-index"
import { cn } from "@/lib/utils"
export function MailSettingsSearchBar({ className }: { className?: string }) {
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [open, setOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const suggestions = useMemo(
() => searchMailSettings(query),
[query]
)
const showDropdown =
open && focused && query.trim().length > 0 && suggestions.length > 0
const navigateTo = useCallback(
(entry: MailSettingsSearchEntry) => {
router.push(entry.href)
setOpen(false)
setQuery("")
setSelectedIndex(-1)
inputRef.current?.blur()
},
[router]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (!showDropdown && e.key !== "Escape") return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((i) =>
i < suggestions.length - 1 ? i + 1 : 0
)
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((i) =>
i > 0 ? i - 1 : suggestions.length - 1
)
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
navigateTo(suggestions[selectedIndex])
} else if (suggestions[0]) {
navigateTo(suggestions[0])
}
break
case "Escape":
e.preventDefault()
setOpen(false)
setSelectedIndex(-1)
inputRef.current?.blur()
break
}
},
[showDropdown, selectedIndex, suggestions, navigateTo]
)
useEffect(() => {
setSelectedIndex(suggestions.length > 0 ? 0 : -1)
}, [query, suggestions.length])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<div
ref={containerRef}
data-mail-settings-search
className={cn(
"relative flex w-full min-w-0 flex-col overflow-visible",
className
)}
>
<div
className={cn(
DRIVE_SEARCH_INPUT_WRAP_CLASS,
"text-[#5f6368] dark:text-[#9aa0a6]",
focused && "shadow-md ring-1 ring-gray-300 dark:ring-gray-600"
)}
>
<div className="pointer-events-none absolute left-3.5 flex items-center">
<Search className="size-5 shrink-0" />
</div>
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => {
setFocused(true)
if (query.trim()) setOpen(true)
}}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
placeholder="Rechercher dans les réglages"
className={cn(
"h-full w-full rounded-full border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground",
query ? "pl-11 pr-12" : "pl-11 pr-4"
)}
role="combobox"
aria-expanded={showDropdown}
aria-controls="mail-settings-search-listbox"
aria-autocomplete="list"
autoComplete="off"
/>
{query ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 shrink-0 rounded-full text-muted-foreground hover:text-foreground"
aria-label="Effacer la recherche"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuery("")
setOpen(false)
inputRef.current?.focus()
}}
>
<X className="size-4" />
</Button>
) : null}
</div>
{showDropdown ? (
<ul
id="mail-settings-search-listbox"
role="listbox"
className={cn(
"absolute left-0 right-0 top-[calc(100%+4px)] z-50 overflow-hidden rounded-2xl py-1",
MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS
)}
>
{suggestions.map((entry, index) => {
const selected = index === selectedIndex
return (
<li key={entry.id} role="presentation">
<button
type="button"
role="option"
aria-selected={selected}
className={cn(
"flex w-full items-start gap-3 px-4 py-2.5 text-left text-sm transition-colors",
selected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60"
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => navigateTo(entry)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{entry.label}
</div>
<div className="truncate text-xs text-muted-foreground">
{entry.sectionLabel}
{entry.description ? ` · ${entry.description}` : null}
</div>
</div>
</button>
</li>
)
})}
</ul>
) : null}
</div>
)
}

View File

@ -2,11 +2,11 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header" import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel" import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel" import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel" import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel" import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
import { ApiTokensPanel } from "@/components/gmail/settings/automation/api-tokens-panel"
export function AutomationSettingsSection() { export function AutomationSettingsSection() {
return ( return (
@ -37,10 +37,7 @@ export function AutomationSettingsSection() {
<SearchProvidersPanel /> <SearchProvidersPanel />
</TabsContent> </TabsContent>
<TabsContent value="tokens" className="mt-4"> <TabsContent value="tokens" className="mt-4">
<SettingsComingSoon <ApiTokensPanel />
title="Tokens API agents"
description="Créez des jetons fine-grained pour agents IA (lecture partielle, envoi, catégorisation)."
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</> </>

View File

@ -21,6 +21,11 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header" import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner" import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
} from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { useAuthReady } from "@/lib/api/use-auth-ready" import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries" import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries" import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
@ -54,12 +59,16 @@ export function SignaturesSettingsSection() {
/> />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-6"> <div className={cn("space-y-6 lg:space-y-0", MAIL_SETTINGS_PAGE_MASONRY_CLASS)}>
<SignatureLibrary <div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
signatures={signatures} <SignatureLibrary
showInitialLoad={showInitialLoad} signatures={signatures}
/> showInitialLoad={showInitialLoad}
<IdentitySignatureAssignments signatures={signatures} /> />
</div>
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
<IdentitySignatureAssignments signatures={signatures} />
</div>
</div> </div>
</> </>
) )
@ -344,15 +353,15 @@ function IdentitySignatureRow({
: NONE_SIGNATURE : NONE_SIGNATURE
return ( return (
<li className="flex flex-col gap-2 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between"> <li className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 rounded-lg border border-border p-3">
<div className="min-w-0"> <div className="min-w-[10rem] max-w-full flex-1">
<p className="text-sm font-medium truncate">{identity.name}</p> <p className="text-sm font-medium">{identity.name}</p>
<p className="text-xs text-muted-foreground truncate">{identity.email}</p> <p className="text-xs text-muted-foreground break-all">{identity.email}</p>
{identity.is_default ? ( {identity.is_default ? (
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p> <p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 items-center gap-2 sm:w-64"> <div className="min-w-[10rem] max-w-full flex-[1_1_10rem]">
<Label className="text-xs sr-only">Signature par défaut</Label> <Label className="text-xs sr-only">Signature par défaut</Label>
<Select <Select
value={current} value={current}

View File

@ -0,0 +1,166 @@
"use client"
import { useMemo, useState } from "react"
import { ChevronRight, Folder } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDriveList } from "@/lib/api/hooks/use-drive-queries"
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 { normalizeDriveFolderPath } from "@/lib/drive/drive-sidebar-tree"
import { MAIL_DRIVE_DEFAULT_FOLDER } from "@/lib/mail/mail-drive"
import { cn } from "@/lib/utils"
export function MailDriveFolderPicker({
open,
onOpenChange,
title,
description,
confirmLabel,
pending,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
confirmLabel: string
pending?: boolean
onConfirm: (folderPath: string) => void | Promise<void>
}) {
const [browsePath, setBrowsePath] = useState(MAIL_DRIVE_DEFAULT_FOLDER)
const list = useDriveList(browsePath, 1, "", open)
const folders = useMemo(
() => (list.data?.files ?? []).filter((f) => f.type === "directory"),
[list.data?.files]
)
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])
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setBrowsePath(MAIL_DRIVE_DEFAULT_FOLDER)
onOpenChange(next)
}}
>
<DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")}
>
<DialogHeader className={cn("border-b px-5 py-4 text-left", DRIVE_DIALOG_DIVIDER)}>
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}>
{title}
</DialogTitle>
{description ? (
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}>
{description}
</DialogDescription>
) : (
<DialogDescription className="sr-only">{title}</DialogDescription>
)}
</DialogHeader>
<div className="flex min-h-[280px] flex-col">
<div
className={cn(
"flex flex-wrap items-center gap-1 border-b px-4 py-2 text-sm",
DRIVE_DIALOG_DIVIDER
)}
>
{crumbs.map((crumb, i) => (
<span key={crumb.path} className="flex min-w-0 items-center gap-1">
{i > 0 ? (
<ChevronRight className={cn("h-3.5 w-3.5 shrink-0", DRIVE_TEXT_SECONDARY)} />
) : null}
<button
type="button"
className={cn(
"truncate rounded px-1 py-0.5 hover:bg-accent",
i === crumbs.length - 1
? cn("font-medium", DRIVE_TEXT_PRIMARY)
: DRIVE_TEXT_SECONDARY
)}
onClick={() => setBrowsePath(crumb.path)}
>
{crumb.label}
</button>
</span>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{list.isLoading ? (
<p className={cn("px-4 py-6 text-sm", DRIVE_TEXT_SECONDARY)}>Chargement</p>
) : folders.length === 0 ? (
<p className={cn("px-4 py-6 text-sm", DRIVE_TEXT_SECONDARY)}>
Enregistrer dans ce dossier
</p>
) : (
folders.map((folder) => (
<button
key={folder.path}
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent",
DRIVE_TEXT_PRIMARY
)}
onClick={() => setBrowsePath(normalizeDriveFolderPath(folder.path))}
>
<Folder className={cn("h-4 w-4 shrink-0", DRIVE_TEXT_SECONDARY)} />
<span className="min-w-0 flex-1 truncate">{displayFileName(folder.name)}</span>
<ChevronRight className={cn("h-4 w-4 shrink-0", DRIVE_TEXT_SECONDARY)} />
</button>
))
)}
</div>
</div>
<DialogFooter className={cn(DRIVE_DIALOG_FOOTER, "px-4 py-3")}>
<Button
type="button"
variant="ghost"
className={DRIVE_BTN_GHOST}
onClick={() => onOpenChange(false)}
>
Annuler
</Button>
<Button
type="button"
className={DRIVE_BTN_PRIMARY}
disabled={pending}
onClick={() => void onConfirm(browsePath)}
>
{pending ? "Enregistrement…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

Some files were not shown because too many files have changed in this diff Show More