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:
parent
07d57f13a8
commit
6ec95262af
@ -13,3 +13,6 @@ NEXT_PUBLIC_APP_URL=http://localhost
|
||||
|
||||
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||
OIDC_CLIENT_SECRET=changeme
|
||||
|
||||
# OnlyOffice Document Server (UltiDrive editor)
|
||||
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { Metadata } from "next"
|
||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contacts - Ultimail",
|
||||
}
|
||||
export const metadata: Metadata = suitePageMetadata({
|
||||
app: "contacts",
|
||||
absoluteTitle: true,
|
||||
title: "Contacts - Ulti Suite",
|
||||
})
|
||||
|
||||
export default function ContactsLayout({
|
||||
children,
|
||||
|
||||
19
app/drive/(browser)/[[...segments]]/layout.tsx
Normal file
19
app/drive/(browser)/[[...segments]]/layout.tsx
Normal 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
|
||||
}
|
||||
271
app/drive/(browser)/[[...segments]]/page.tsx
Normal file
271
app/drive/(browser)/[[...segments]]/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
app/drive/(browser)/layout.tsx
Normal file
5
app/drive/(browser)/layout.tsx
Normal 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>
|
||||
}
|
||||
21
app/drive/edit/[fileId]/layout.tsx
Normal file
21
app/drive/edit/[fileId]/layout.tsx
Normal 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}</>
|
||||
}
|
||||
12
app/drive/edit/[fileId]/page.tsx
Normal file
12
app/drive/edit/[fileId]/page.tsx
Normal 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
8
app/drive/layout.tsx
Normal 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
|
||||
}
|
||||
91
app/drive/s/[token]/[[...path]]/page.tsx
Normal file
91
app/drive/s/[token]/[[...path]]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
app/drive/s/[token]/edit/[[...path]]/page.tsx
Normal file
30
app/drive/s/[token]/edit/[[...path]]/page.tsx
Normal 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
12
app/drive/s/layout.tsx
Normal 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
|
||||
}
|
||||
131
app/globals.css
131
app/globals.css
@ -1,5 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import '../styles/onlyoffice-theme.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@ -63,6 +64,11 @@
|
||||
--mail-list-chip-text: #3c4043;
|
||||
--mail-list-chip-muted: #f1f3f4;
|
||||
--mail-row-checkbox-border: #c2c2c2;
|
||||
--drive-canvas: var(--app-canvas);
|
||||
--drive-sidebar-foreground: var(--mail-text);
|
||||
--drive-surface: var(--mail-surface);
|
||||
--drive-toolbar: var(--mail-surface-elevated);
|
||||
--suite-surface-elevated: var(--mail-surface-elevated);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -91,6 +97,11 @@
|
||||
--mail-list-chip-text: #e8eaed;
|
||||
--mail-list-chip-muted: #3c4043;
|
||||
--mail-row-checkbox-border: #9aa0a6;
|
||||
--drive-canvas: var(--app-canvas);
|
||||
--drive-sidebar-foreground: var(--mail-text);
|
||||
--drive-surface: var(--mail-surface);
|
||||
--drive-toolbar: var(--mail-surface-elevated);
|
||||
--suite-surface-elevated: var(--mail-surface-elevated);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
@ -186,6 +197,13 @@
|
||||
--color-mail-surface: var(--mail-surface);
|
||||
--color-mail-surface-elevated: var(--mail-surface-elevated);
|
||||
--color-mail-surface-muted: var(--mail-surface-muted);
|
||||
--color-mail-text: var(--mail-text);
|
||||
--color-mail-text-strong: var(--mail-text-strong);
|
||||
--color-mail-text-muted: var(--mail-text-muted);
|
||||
--color-mail-active: var(--mail-active);
|
||||
--color-mail-nav-selected: var(--mail-nav-selected);
|
||||
--color-mail-nav-selected-fg: var(--mail-nav-selected-fg);
|
||||
--color-mail-nav-hover: var(--mail-nav-hover);
|
||||
--color-mail-border: var(--mail-border);
|
||||
--color-mail-border-subtle: var(--mail-border-subtle);
|
||||
--color-mail-invitation: var(--mail-invitation);
|
||||
@ -194,6 +212,9 @@
|
||||
--color-mail-list-chip-text: var(--mail-list-chip-text);
|
||||
--color-mail-list-chip-muted: var(--mail-list-chip-muted);
|
||||
--color-mail-row-checkbox-border: var(--mail-row-checkbox-border);
|
||||
--color-drive-canvas: var(--drive-canvas);
|
||||
--color-drive-surface: var(--drive-surface);
|
||||
--color-drive-sidebar-foreground: var(--drive-sidebar-foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@ -633,6 +654,61 @@ html[data-mail-background]:not([data-mail-background='none'])
|
||||
background-color: color-mix(in srgb, var(--mail-surface) 88%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Drive : pas de fond décoratif mail — surfaces opaques (carte arrondie + chrome). */
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app].ultimail-app {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-app-canvas) {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface, .bg-white) {
|
||||
background-color: var(--mail-surface) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface-elevated) {
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
}
|
||||
|
||||
/* Contacts : pas de fond décoratif mail — surfaces opaques. */
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app].ultimail-app {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-app-canvas) {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface, .bg-white) {
|
||||
background-color: var(--mail-surface) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-elevated) {
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-muted) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
}
|
||||
|
||||
/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
|
||||
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none'])
|
||||
[data-mail-settings-app]
|
||||
[data-mail-settings-sidebar] {
|
||||
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none'])
|
||||
[data-mail-settings-app]
|
||||
:where([data-mail-settings-main]) {
|
||||
background-color: var(--mail-surface) !important;
|
||||
}
|
||||
|
||||
.ultimail-app {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
@ -744,6 +820,22 @@ html.dark[data-mail-background]:not([data-mail-background='none'])
|
||||
/* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
|
||||
html.dark .ultimail-app {
|
||||
color-scheme: dark;
|
||||
/* Tokens shadcn → gris mail (cards réglages, popovers, champs). */
|
||||
--background: var(--app-canvas);
|
||||
--foreground: var(--mail-text);
|
||||
--card: var(--mail-surface-elevated);
|
||||
--card-foreground: var(--mail-text);
|
||||
--popover: var(--mail-surface-elevated);
|
||||
--popover-foreground: var(--mail-text);
|
||||
--secondary: var(--mail-surface-muted);
|
||||
--secondary-foreground: var(--mail-text);
|
||||
--muted: var(--mail-surface-muted);
|
||||
--muted-foreground: var(--mail-text-muted);
|
||||
--accent: var(--mail-nav-hover);
|
||||
--accent-foreground: var(--mail-text);
|
||||
--border: var(--mail-border-subtle);
|
||||
--input: var(--mail-border-subtle);
|
||||
--ring: var(--mail-border);
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where(.bg-white) {
|
||||
@ -767,7 +859,7 @@ html.dark .ultimail-app :where(.bg-\[\#e8f0fe\]) {
|
||||
background-color: var(--mail-active) !important;
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where([class*='bg-white/']) {
|
||||
html.dark .ultimail-app :where(.bg-white\/80, .bg-white\/90, .bg-white\/95) {
|
||||
background-color: color-mix(in srgb, var(--mail-surface) 82%, transparent) !important;
|
||||
}
|
||||
|
||||
@ -831,6 +923,28 @@ html.dark [data-slot='menubar-content'] {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */
|
||||
html.dark [data-drive-menu-surface],
|
||||
html.dark [data-contacts-menu-surface] {
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
color: var(--mail-text) !important;
|
||||
border-color: var(--mail-border-subtle) !important;
|
||||
}
|
||||
|
||||
html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item']:focus,
|
||||
html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted],
|
||||
html.dark [data-drive-menu-surface] [data-slot='context-menu-item']:focus,
|
||||
html.dark [data-drive-menu-surface] [data-slot='context-menu-item'][data-highlighted],
|
||||
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item']:focus,
|
||||
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted],
|
||||
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger']:focus,
|
||||
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger'][data-state='open'],
|
||||
html.dark [data-contacts-menu-surface] [data-slot='select-item']:focus,
|
||||
html.dark [data-contacts-menu-surface] [data-slot='select-item'][data-highlighted] {
|
||||
background-color: var(--mail-nav-hover) !important;
|
||||
color: var(--mail-text) !important;
|
||||
}
|
||||
|
||||
html.dark [data-slot='dropdown-menu-item']:focus,
|
||||
html.dark [data-slot='dropdown-menu-item'][data-highlighted],
|
||||
html.dark [data-slot='dropdown-menu-sub-trigger']:focus,
|
||||
@ -999,3 +1113,18 @@ html.dark :where([data-contacts-panel] .hover\:bg-gray-100:hover, [data-contacts
|
||||
html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
|
||||
html.dark .ultimail-app :where(.bg-background) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg-muted\/40) {
|
||||
background-color: color-mix(in srgb, var(--mail-surface-muted) 72%, transparent) !important;
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
border-color: var(--mail-border-subtle) !important;
|
||||
color: var(--mail-text) !important;
|
||||
}
|
||||
|
||||
@ -7,15 +7,12 @@ import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||
import { QueryProvider } from '@/lib/api/query-provider'
|
||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||
import { MailToaster } from '@/components/gmail/mail-toaster'
|
||||
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Ultimail',
|
||||
description: 'Interface client mail Ultimail (clone UI) construite avec React',
|
||||
generator: 'v0.app',
|
||||
}
|
||||
export const metadata: Metadata = suiteRootMetadata()
|
||||
|
||||
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { LoginChrome } from "@/components/auth/login-chrome"
|
||||
import type { Metadata } from "next"
|
||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||
|
||||
export const metadata: Metadata = suitePageMetadata({
|
||||
app: "suite",
|
||||
title: "Connexion",
|
||||
})
|
||||
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { MailAppShell } from "./mail-app-shell"
|
||||
import type { Metadata } from "next"
|
||||
import { suitePageMetadata, MAIL_INBOX_DOCUMENT_TITLE } from "@/lib/suite/page-metadata"
|
||||
|
||||
export const metadata: Metadata = suitePageMetadata({
|
||||
app: "mail",
|
||||
absoluteTitle: true,
|
||||
title: MAIL_INBOX_DOCUMENT_TITLE,
|
||||
})
|
||||
|
||||
export default function MailLayout({
|
||||
children,
|
||||
|
||||
@ -11,6 +11,8 @@ import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
||||
import { useMailRoute } from "@/hooks/use-mail-route"
|
||||
import { parseSearchParams } from "@/lib/mail-search/search-params"
|
||||
import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
|
||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||
@ -27,6 +29,7 @@ import { ScheduledMailProvider } from "@/lib/scheduled-mail-context"
|
||||
import { ComposeModalManager } from "@/components/gmail/compose-modal"
|
||||
import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
|
||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||
import { MailDocumentTitle } from "@/components/gmail/mail-document-title"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
||||
@ -41,6 +44,7 @@ import { MailSignaturesSync } from "@/components/gmail/mail-signatures-sync"
|
||||
import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge"
|
||||
import { useWebSocket } from "@/lib/api/ws"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
||||
|
||||
const MAIL_SETTINGS_PATH = "/mail/settings"
|
||||
|
||||
@ -52,6 +56,10 @@ function MailAppInner() {
|
||||
const router = useRouter()
|
||||
const { route, navigateRoute, searchParams: currentSearchParams } =
|
||||
useMailRoute()
|
||||
const activeSearchQuery =
|
||||
route.folderId === "search"
|
||||
? searchParamsToDisplayQuery(parseSearchParams(currentSearchParams))
|
||||
: ""
|
||||
|
||||
const isXs = useIsXs()
|
||||
const touchNav = useTouchNav()
|
||||
@ -108,6 +116,7 @@ function MailAppInner() {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<MailDocumentTitle />
|
||||
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||
{!splitView ? (
|
||||
<div className="hidden sm:block">
|
||||
@ -191,14 +200,14 @@ function MailAppInner() {
|
||||
}
|
||||
xsViewChrome={xsViewChrome}
|
||||
onOpenSearch={() => setMobileSearchOpen(true)}
|
||||
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
||||
searchQuery={activeSearchQuery}
|
||||
onClearSearch={() => router.push("/mail/inbox")}
|
||||
/>
|
||||
) : null}
|
||||
<MobileSearchOverlay
|
||||
open={mobileSearchOpen}
|
||||
onClose={() => setMobileSearchOpen(false)}
|
||||
initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
||||
initialQuery={activeSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
</SidebarNavProvider>
|
||||
@ -260,6 +269,7 @@ export function MailAppShell({
|
||||
<QuickSettingsRoot />
|
||||
<MoveDragIndicator />
|
||||
<ComposeModalManager />
|
||||
<FilePreviewDialog />
|
||||
</EmailDragProvider>
|
||||
</ScheduledMailProvider>
|
||||
</ComposeProvider>
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout"
|
||||
import type { Metadata } from "next"
|
||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||
|
||||
export const metadata: Metadata = suitePageMetadata({
|
||||
app: "mail",
|
||||
title: "Réglages",
|
||||
})
|
||||
|
||||
export default function MailSettingsRootLayout({
|
||||
children,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc-config"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
@ -11,6 +11,7 @@ const REFRESH_LEAD_MS = 5 * 60 * 1000
|
||||
const REFRESH_CHECK_MS = 60 * 1000
|
||||
|
||||
function isPublicPath(pathname: string) {
|
||||
if (pathname.startsWith("/drive/s/")) return true
|
||||
return PUBLIC_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
||||
)
|
||||
@ -159,7 +160,10 @@ export function useAuthLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||
logout()
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("ultimail-auth")
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
for (const legacy of LEGACY_AUTH_KEYS) {
|
||||
localStorage.removeItem(legacy)
|
||||
}
|
||||
}
|
||||
router.replace("/login")
|
||||
}
|
||||
|
||||
247
components/drive/breadcrumb-folder-menu.tsx
Normal file
247
components/drive/breadcrumb-folder-menu.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
119
components/drive/breadcrumb-nav.tsx
Normal file
119
components/drive/breadcrumb-nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
components/drive/drive-app-shell.tsx
Normal file
43
components/drive/drive-app-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
components/drive/drive-browser-chrome.tsx
Normal file
109
components/drive/drive-browser-chrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
351
components/drive/drive-bulk-toolbar.tsx
Normal file
351
components/drive/drive-bulk-toolbar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
components/drive/drive-card-ref-context.tsx
Normal file
36
components/drive/drive-card-ref-context.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
171
components/drive/drive-file-actions-menu.tsx
Normal file
171
components/drive/drive-file-actions-menu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
416
components/drive/drive-file-context-menu.tsx
Normal file
416
components/drive/drive-file-context-menu.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
370
components/drive/drive-file-menu-actions.tsx
Normal file
370
components/drive/drive-file-menu-actions.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
382
components/drive/drive-filter-bar.tsx
Normal file
382
components/drive/drive-filter-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
components/drive/drive-folder-grid-card.tsx
Normal file
102
components/drive/drive-folder-grid-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
components/drive/drive-grid-card.tsx
Normal file
112
components/drive/drive-grid-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
components/drive/drive-grid-view.tsx
Normal file
127
components/drive/drive-grid-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
components/drive/drive-header.tsx
Normal file
74
components/drive/drive-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
components/drive/drive-list-modified.tsx
Normal file
31
components/drive/drive-list-modified.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
components/drive/drive-marquee-surface.tsx
Normal file
41
components/drive/drive-marquee-surface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
components/drive/drive-mobile-bottom-bar.tsx
Normal file
148
components/drive/drive-mobile-bottom-bar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
components/drive/drive-mobile-search-sheet.tsx
Normal file
129
components/drive/drive-mobile-search-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
components/drive/drive-move-dialog.tsx
Normal file
207
components/drive/drive-move-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
components/drive/drive-name-dialog.tsx
Normal file
127
components/drive/drive-name-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
components/drive/drive-new-sheet.tsx
Normal file
171
components/drive/drive-new-sheet.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
6
components/drive/drive-scroll-end-spacer.tsx
Normal file
6
components/drive/drive-scroll-end-spacer.tsx
Normal 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)} />
|
||||
}
|
||||
164
components/drive/drive-search-bar.tsx
Normal file
164
components/drive/drive-search-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
components/drive/drive-search-breadcrumb.tsx
Normal file
25
components/drive/drive-search-breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
components/drive/drive-search-suggestions.tsx
Normal file
171
components/drive/drive-search-suggestions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
components/drive/drive-sidebar.tsx
Normal file
173
components/drive/drive-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
components/drive/drive-sort-menu.tsx
Normal file
133
components/drive/drive-sort-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
components/drive/drive-view-mode-toggle.tsx
Normal file
46
components/drive/drive-view-mode-toggle.tsx
Normal 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 d’affichage"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
162
components/drive/file-browser.tsx
Normal file
162
components/drive/file-browser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
610
components/drive/file-preview-dialog.tsx
Normal file
610
components/drive/file-preview-dialog.tsx
Normal 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 l’aperçu texte. Téléchargez-le.")
|
||||
return
|
||||
}
|
||||
setTextContent(await blob.text())
|
||||
return
|
||||
}
|
||||
if (isSvg) {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current)
|
||||
blobUrlRef.current = null
|
||||
}
|
||||
setBlobUrl(null)
|
||||
setSvgMarkup(await blob.text())
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const previous = blobUrlRef.current
|
||||
blobUrlRef.current = url
|
||||
setBlobUrl(url)
|
||||
if (previous && previous !== url) {
|
||||
URL.revokeObjectURL(previous)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const msg =
|
||||
err instanceof ApiRequestError
|
||||
? err.message
|
||||
: "Impossible de charger l’aperçu."
|
||||
setError(msg)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
file?.path,
|
||||
file?.mime_type,
|
||||
file?.mailAttachmentId,
|
||||
kind,
|
||||
isSvg,
|
||||
publicShare?.token,
|
||||
publicShare?.password,
|
||||
])
|
||||
|
||||
const previewReady =
|
||||
kind === "text"
|
||||
? textContent !== null
|
||||
: isSvg
|
||||
? svgMarkup !== null
|
||||
: Boolean(blobUrl)
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFiles.length === 0) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault()
|
||||
stepPreview(-1)
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault()
|
||||
stepPreview(1)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [previewFiles.length, stepPreview])
|
||||
|
||||
useEffect(() => {
|
||||
return () => revokeBlobUrl()
|
||||
}, [])
|
||||
|
||||
const title = file ? displayFileName(file.name) : ""
|
||||
const open = Boolean(file && kind)
|
||||
|
||||
const onShare = () => {
|
||||
if (!file) return
|
||||
setSharePath(file.path, "file")
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
if (!file) return
|
||||
const next = !file.is_favorite
|
||||
try {
|
||||
await mutations.favorite.mutateAsync({ path: file.path, favorite: next })
|
||||
updatePreviewFavorite(file.path, next)
|
||||
toast.success(next ? "Ajouté aux favoris" : "Retiré des favoris")
|
||||
} catch {
|
||||
toast.error("Impossible de modifier les favoris")
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!file) return
|
||||
try {
|
||||
await mutations.deleteFile.mutateAsync(file.path)
|
||||
removePreviewFile(file.path)
|
||||
toast.success("Supprimé")
|
||||
} catch {
|
||||
toast.error("Impossible de supprimer")
|
||||
}
|
||||
}
|
||||
|
||||
const favoriteLabel = file?.is_favorite ? "Retirer des favoris" : "Ajouter aux favoris"
|
||||
|
||||
const onMailDownload = () => {
|
||||
if (!file?.mailAttachmentId) return
|
||||
void apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`).then((blob) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = displayFileName(file.name)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
const onMailSaveToDrive = async (folderPath: string) => {
|
||||
if (!file?.mailAttachmentId || !mailMessageId) return
|
||||
try {
|
||||
const drivePath = await saveToDrive.mutateAsync({
|
||||
attachmentId: file.mailAttachmentId,
|
||||
folderPath,
|
||||
})
|
||||
useDriveUIStore.setState((state) => ({
|
||||
previewFiles: state.previewFiles.map((f) =>
|
||||
f.mailAttachmentId === file.mailAttachmentId
|
||||
? { ...f, path: drivePath }
|
||||
: f
|
||||
),
|
||||
}))
|
||||
setMailSavePickerOpen(false)
|
||||
toast.success(mailDriveSaveSuccessMessage(folderPath), {
|
||||
action: {
|
||||
label: "Ouvrir le dossier",
|
||||
onClick: () => router.push(mailDriveFolderHref(folderPath)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(mailDriveSaveErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
184
components/drive/file-thumbnail.tsx
Normal file
184
components/drive/file-thumbnail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
components/drive/new-menu.tsx
Normal file
136
components/drive/new-menu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
215
components/drive/office-editor.tsx
Normal file
215
components/drive/office-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
415
components/drive/pdf-preview-viewer.tsx
Normal file
415
components/drive/pdf-preview-viewer.tsx
Normal 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 d’afficher 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>
|
||||
)
|
||||
}
|
||||
225
components/drive/public-office-editor.tsx
Normal file
225
components/drive/public-office-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
274
components/drive/public-share-folder-view.tsx
Normal file
274
components/drive/public-share-folder-view.tsx
Normal 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 d’importer 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>
|
||||
)
|
||||
}
|
||||
286
components/drive/public-share-view.tsx
Normal file
286
components/drive/public-share-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
components/drive/quota-bar.tsx
Normal file
27
components/drive/quota-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
789
components/drive/share-dialog.tsx
Normal file
789
components/drive/share-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
282
components/drive/sidebar-folder-tree.tsx
Normal file
282
components/drive/sidebar-folder-tree.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
32
components/drive/svg-preview-viewer.tsx
Normal file
32
components/drive/svg-preview-viewer.tsx
Normal 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
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -55,6 +55,7 @@ import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PAGE_ICON_BTN_CLASS,
|
||||
CONTACTS_PAGE_SAVE_BTN_CLASS,
|
||||
CONTACTS_MENU_SURFACE_CLASS,
|
||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||
CONTACTS_PANEL_CARD_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é
|
||||
</button>
|
||||
</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>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{availableLabels.map((row) => {
|
||||
@ -562,7 +567,7 @@ function CompactSelect({ value, onValueChange, options, placeholder }: { value:
|
||||
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||
</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>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@ -108,8 +108,12 @@ export function ContactsAppShell() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-contacts-app
|
||||
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 && (
|
||||
<button
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/contacts/bulk-edit-fields"
|
||||
import {
|
||||
CONTACTS_FIELD_CLASS,
|
||||
CONTACTS_MENU_SURFACE_CLASS,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PRIMARY_BTN_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
@ -107,7 +108,7 @@ export function ContactsBulkEditDialog({
|
||||
<SelectTrigger className={CONTACTS_FIELD_CLASS}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
|
||||
{CONTACT_BULK_EDIT_FIELDS.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { Menu, Search, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
|
||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||
import {
|
||||
CONTACTS_ICON_BTN_CLASS,
|
||||
CONTACTS_SEARCH_BAR_CLASS,
|
||||
|
||||
@ -27,6 +27,7 @@ import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CONTACTS_CREATE_BTN_CLASS,
|
||||
CONTACTS_FIELD_CLASS,
|
||||
CONTACTS_HEADING_TEXT,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_NAV_ACTIVE_CLASS,
|
||||
CONTACTS_NAV_ICON_MUTED,
|
||||
@ -34,12 +35,13 @@ import {
|
||||
CONTACTS_CREATE_BTN_LABEL_CLASS,
|
||||
CONTACTS_SIDEBAR_CLASS,
|
||||
} 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 { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
|
||||
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import { ContactsPanelLogo } from "@/components/gmail/contacts/contacts-panel-logo"
|
||||
import type { ContactsPageView } from "./contacts-app-shell"
|
||||
|
||||
interface ContactsSidebarProps {
|
||||
@ -146,15 +148,10 @@ export function ContactsSidebar({
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
<ContactsPanelLogo
|
||||
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"
|
||||
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>
|
||||
titleClassName={cn("text-[22px] font-normal", CONTACTS_HEADING_TEXT)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create button */}
|
||||
@ -170,7 +167,11 @@ export function ContactsSidebar({
|
||||
<ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
|
||||
</button>
|
||||
</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}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Créer un contact
|
||||
|
||||
@ -41,7 +41,7 @@ import {
|
||||
CONTACTS_TABLE_TOOLBAR_CLASS,
|
||||
CONTACTS_TABLE_STICKY_HEAD_CLASS,
|
||||
} 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 { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
|
||||
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
|
||||
@ -359,8 +359,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
data-contacts-menu-surface
|
||||
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",
|
||||
)}
|
||||
>
|
||||
@ -410,7 +411,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
</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}>
|
||||
Exporter au format vCard (.vcf)
|
||||
</DropdownMenuItem>
|
||||
@ -444,7 +449,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
</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 && (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
@ -453,8 +462,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
Ajouter / Retirer des libellés
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
data-contacts-menu-surface
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -13,6 +13,7 @@ import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||
import {
|
||||
CONTACTS_MENU_SURFACE_CLASS,
|
||||
CONTACTS_HEADING_TEXT,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PAGE_BANNER_CLASS,
|
||||
@ -97,7 +98,11 @@ export function TrashView() {
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
data-contacts-menu-surface
|
||||
className={CONTACTS_MENU_SURFACE_CLASS}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => restoreContact(contact.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Restaurer
|
||||
|
||||
@ -52,6 +52,7 @@ import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import {
|
||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||
CONTACTS_PANEL_CARD_CLASS,
|
||||
CONTACTS_MENU_SURFACE_CLASS,
|
||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
||||
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
||||
@ -473,7 +474,11 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
Libellé
|
||||
</button>
|
||||
</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>
|
||||
@ -941,7 +946,7 @@ function CompactSelect({
|
||||
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Users } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||
import {
|
||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||
CONTACTS_PANEL_TITLE_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
|
||||
const CONTACTS_MARK_SRC = suitePublicAsset("/contacts-mark.svg")
|
||||
|
||||
type ContactsPanelLogoProps = {
|
||||
onClick: () => void
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
@ -23,8 +31,14 @@ export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps
|
||||
)}
|
||||
aria-label="Liste des contacts"
|
||||
>
|
||||
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
|
||||
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span>
|
||||
<img
|
||||
src={CONTACTS_MARK_SRC}
|
||||
alt=""
|
||||
className={cn("shrink-0 object-contain", markClassName)}
|
||||
draggable={false}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={cn("truncate", titleClassName)}>Contacts</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -62,14 +62,25 @@ export function EmailListAttachmentRow({
|
||||
aria-hidden
|
||||
>
|
||||
{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 className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
{collapsed && attachments.length > 1 ? (
|
||||
<>
|
||||
<ListAttachmentChip att={attachments[0]!} />
|
||||
<ListAttachmentChip
|
||||
att={attachments[0]!}
|
||||
messageId={emailId}
|
||||
attachments={attachments}
|
||||
attachmentIndex={0}
|
||||
/>
|
||||
<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"
|
||||
title={othersTitle}
|
||||
@ -79,7 +90,13 @@ export function EmailListAttachmentRow({
|
||||
</>
|
||||
) : (
|
||||
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>
|
||||
|
||||
@ -1,11 +1,44 @@
|
||||
"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 {
|
||||
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 (
|
||||
<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" ? (
|
||||
<File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden />
|
||||
) : 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 />
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ import {
|
||||
buildSearchUrl,
|
||||
type SearchParams,
|
||||
} from "@/lib/mail-search/search-params"
|
||||
import { searchParamsToMessageSearchFilter } from "@/lib/mail-search/search-filter"
|
||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
|
||||
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
|
||||
@ -192,15 +193,7 @@ export function useEmailListData({
|
||||
|
||||
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
||||
if (!isSearchMode || !searchParams) return null
|
||||
return {
|
||||
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,
|
||||
}
|
||||
return searchParamsToMessageSearchFilter(searchParams, accountId)
|
||||
}, [isSearchMode, searchParams, accountId])
|
||||
|
||||
const messagesQuery = useMessages(
|
||||
|
||||
@ -15,8 +15,9 @@ import {
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||
import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments"
|
||||
import { attachmentsForEmailList } from "@/lib/attachment-display"
|
||||
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
|
||||
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 {
|
||||
mailFlagIsRead,
|
||||
@ -69,6 +70,10 @@ import {
|
||||
formatApiMessageBody,
|
||||
plainTextBodyFallback,
|
||||
} from "@/components/gmail/email-view/email-view-messages"
|
||||
import {
|
||||
ConversationAttachmentsSection,
|
||||
type ConversationAttachmentEntry,
|
||||
} from "@/components/gmail/email-view/message-attachments"
|
||||
|
||||
function apiToLegacyEmail(
|
||||
msg: ApiMessageSummary,
|
||||
@ -220,19 +225,69 @@ export function EmailView({
|
||||
[email, fullMessage, threadMessages]
|
||||
)
|
||||
|
||||
const { data: fetchedAttachments } = useMessageAttachments(
|
||||
email.id,
|
||||
email.has_attachments
|
||||
const allThreadMessages = useMemo((): ApiMessageFull[] => {
|
||||
const main: ApiMessageFull = fullMessage
|
||||
? { ...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(
|
||||
(): EmailAttachment[] =>
|
||||
attachmentsForEmailList({
|
||||
hasAttachment: email.has_attachments,
|
||||
attachments: fetchedAttachments,
|
||||
}),
|
||||
[email.has_attachments, fetchedAttachments]
|
||||
() => resolveMessageAttachments(email),
|
||||
[resolveMessageAttachments, email]
|
||||
)
|
||||
|
||||
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 { savedThreadReplyDrafts } = useComposeDrafts()
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
@ -382,6 +437,7 @@ export function EmailView({
|
||||
selfEmails={selfEmails}
|
||||
selfDisplayName={selfDisplayName}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
attachments={resolveMessageAttachments(msg)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -419,10 +475,15 @@ export function EmailView({
|
||||
selfEmails={selfEmails}
|
||||
selfDisplayName={selfDisplayName}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
attachments={resolveMessageAttachments(msg)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{otherThreadCount > 0 && conversationAttachmentEntries.length > 0 ? (
|
||||
<ConversationAttachmentsSection entries={conversationAttachmentEntries} />
|
||||
) : null}
|
||||
|
||||
{showReplyForwardBar ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
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 {
|
||||
Tooltip,
|
||||
@ -86,6 +86,7 @@ export function ThreadPriorMessage({
|
||||
selfEmails,
|
||||
selfDisplayName,
|
||||
collapseQuotedReplies = false,
|
||||
attachments = [],
|
||||
}: {
|
||||
message: ApiMessageFull
|
||||
isExpanded: boolean
|
||||
@ -96,6 +97,7 @@ export function ThreadPriorMessage({
|
||||
selfEmails: string[]
|
||||
selfDisplayName?: string
|
||||
collapseQuotedReplies?: boolean
|
||||
attachments?: EmailAttachment[]
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const loadFull = isExpanded || detailsOpen
|
||||
@ -137,6 +139,13 @@ export function ThreadPriorMessage({
|
||||
message={message}
|
||||
senderName={resolved.name}
|
||||
senderEmail={resolved.email}
|
||||
attachmentCount={
|
||||
attachments.length > 0
|
||||
? attachments.length
|
||||
: message.has_attachments
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)
|
||||
@ -152,6 +161,7 @@ export function ThreadPriorMessage({
|
||||
isSpam={isSpam}
|
||||
isLast={false}
|
||||
starred={mailFlagIsStarred(message.flags ?? [])}
|
||||
attachments={attachments}
|
||||
onCollapse={onToggle}
|
||||
onPrintConversation={onPrintConversation}
|
||||
onReply={onReply}
|
||||
@ -169,11 +179,13 @@ export function CollapsedMessage({
|
||||
message,
|
||||
senderName: senderNameProp,
|
||||
senderEmail: senderEmailProp,
|
||||
attachmentCount = 0,
|
||||
onClick,
|
||||
}: {
|
||||
message: ApiMessageFull
|
||||
senderName?: string
|
||||
senderEmail?: string
|
||||
attachmentCount?: number
|
||||
onClick: () => void
|
||||
}) {
|
||||
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>
|
||||
</ContactHoverCard>
|
||||
<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
|
||||
iso={message.date}
|
||||
variant="preview"
|
||||
@ -302,7 +327,7 @@ export function ExpandedMessage({
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<MessageAttachmentsSection attachments={attachments} />
|
||||
<MessageAttachmentsSection messageId={messageId} attachments={attachments} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
214
components/gmail/email-view/mail-attachment-thumbnail.tsx
Normal file
214
components/gmail/email-view/mail-attachment-thumbnail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,12 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Info,
|
||||
HardDrive,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -20,24 +25,35 @@ import {
|
||||
shouldUseAttachmentPillsInPreview,
|
||||
} from "@/lib/attachment-display"
|
||||
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 (
|
||||
<>
|
||||
<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]">
|
||||
{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>
|
||||
<MailAttachmentThumbnail attachment={attachment} />
|
||||
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
|
||||
{kind === "pdf" ? (
|
||||
<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 />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
|
||||
{name}
|
||||
{attachment.name}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageAttachmentPill({
|
||||
name,
|
||||
kind,
|
||||
sizeBytes,
|
||||
}: {
|
||||
name: string
|
||||
kind: EmailAttachmentKind
|
||||
sizeBytes?: number
|
||||
}) {
|
||||
const tip = attachmentPreviewTooltip(name, sizeBytes)
|
||||
function DriveLocationBadge({ folderPath }: { folderPath: string }) {
|
||||
const label = mailDriveFolderPathLabel(folderPath)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{kind === "pdf" ? (
|
||||
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
||||
) : kind === "image" ? (
|
||||
<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>
|
||||
<Link
|
||||
href={mailDriveFolderHref(folderPath)}
|
||||
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"
|
||||
title={folderPath}
|
||||
>
|
||||
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-70" aria-hidden />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
|
||||
export function MessageAttachmentsSection({
|
||||
messageId,
|
||||
attachments,
|
||||
}: {
|
||||
messageId: string
|
||||
attachments: EmailAttachment[]
|
||||
}) {
|
||||
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
|
||||
|
||||
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
|
||||
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 (
|
||||
<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">
|
||||
<span className="min-w-0 truncate">
|
||||
{summary}
|
||||
@ -120,14 +355,6 @@ export function MessageAttachmentsSection({ attachments }: { attachments: EmailA
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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
|
||||
@ -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]"
|
||||
}
|
||||
role="list"
|
||||
aria-label="Pièces jointes"
|
||||
aria-label="Pièces jointes de la conversation"
|
||||
>
|
||||
{attachments.map((att, index) => {
|
||||
const kind = resolveAttachmentKind(att.name, att.kind)
|
||||
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
|
||||
{flat.map((item, flatIndex) => {
|
||||
const kind = resolveAttachmentKind(item.attachment.name, item.attachment.kind)
|
||||
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) {
|
||||
return (
|
||||
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
|
||||
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
|
||||
<div
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
<TooltipTrigger asChild>
|
||||
<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"
|
||||
>
|
||||
<MessageAttachmentCard name={att.name} kind={kind} />
|
||||
<MessageAttachmentCard attachment={item.attachment} kind={kind} />
|
||||
</button>
|
||||
</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}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -114,6 +114,7 @@ export function SandboxedContent({
|
||||
csp: cspContent,
|
||||
wrapperCss: themeCss,
|
||||
plainTextFallback,
|
||||
loadAppFont: !blockRemoteContent,
|
||||
bodyTailCss: isDark
|
||||
? blockRemoteContent
|
||||
? emailPreviewDarkTailOverrideCss()
|
||||
|
||||
@ -1,235 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
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>
|
||||
)
|
||||
}
|
||||
export { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||
|
||||
@ -4,7 +4,8 @@ import { Menu, Search } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
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"
|
||||
|
||||
interface HeaderProps {
|
||||
@ -22,6 +23,8 @@ export function Header({
|
||||
hideSearch = false,
|
||||
onOpenMobileSearch,
|
||||
}: HeaderProps) {
|
||||
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
||||
|
||||
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">
|
||||
{/* 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" />
|
||||
</div>
|
||||
)}
|
||||
<HeaderAccountActions />
|
||||
<HeaderAccountActions onSettingsClick={() => openQuickSettings(true)} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
39
components/gmail/mail-document-title.tsx
Normal file
39
components/gmail/mail-document-title.tsx
Normal 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
|
||||
}
|
||||
@ -31,6 +31,7 @@ import {
|
||||
import {
|
||||
parseSearchParams,
|
||||
} from "@/lib/mail-search/search-params"
|
||||
import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
|
||||
import {
|
||||
buildQuickSearchParams,
|
||||
submitMailSearch,
|
||||
@ -79,6 +80,8 @@ export function MailSearchBar({
|
||||
toggleChipAttachment,
|
||||
toggleChipLast7Days,
|
||||
toggleChipFromMe,
|
||||
resetChips,
|
||||
syncChipsFromParams,
|
||||
reset,
|
||||
} = useMailSearchStore.getState()
|
||||
|
||||
@ -87,11 +90,14 @@ export function MailSearchBar({
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const q = currentSearchParams?.q ?? ""
|
||||
if (q && !inputValue) {
|
||||
setInputValue(q)
|
||||
if (!isOnSearchPage) {
|
||||
resetChips()
|
||||
return
|
||||
}
|
||||
}, [currentSearchParams?.q])
|
||||
const displayQuery = searchParamsToDisplayQuery(currentSearchParams)
|
||||
setInputValue(displayQuery)
|
||||
syncChipsFromParams(currentSearchParams, account?.email)
|
||||
}, [isOnSearchPage, currentSearchParams, account?.email, setInputValue, resetChips, syncChipsFromParams])
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||
@ -131,6 +137,7 @@ export function MailSearchBar({
|
||||
if (!Object.keys(params).length) return
|
||||
submitMailSearch(router, params, {
|
||||
onAfter: () => {
|
||||
setInputValue(q.trim())
|
||||
setDropdownOpen(false)
|
||||
inputRef.current?.blur()
|
||||
},
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
"use client"
|
||||
|
||||
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 type {
|
||||
InboxSortMode,
|
||||
@ -99,7 +103,7 @@ function ThemeModePicker({
|
||||
compact?: boolean
|
||||
}) {
|
||||
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) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
@ -136,14 +140,23 @@ function SettingsSection({
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
variant = "panel",
|
||||
}: {
|
||||
title: string
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: "panel" | "page"
|
||||
}) {
|
||||
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} />
|
||||
{children}
|
||||
</section>
|
||||
@ -186,11 +199,11 @@ export function MailSettingsFields({
|
||||
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
||||
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) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
@ -205,7 +218,7 @@ export function MailSettingsFields({
|
||||
|
||||
<SettingsSection
|
||||
title="Thème"
|
||||
className={sectionClassName}
|
||||
variant={variant}
|
||||
action={
|
||||
variant === "panel" && onOpenThemeDialog ? (
|
||||
<button
|
||||
@ -230,7 +243,7 @@ export function MailSettingsFields({
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">
|
||||
Arrière-plan
|
||||
</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) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
@ -266,10 +279,7 @@ export function MailSettingsFields({
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Type de boîte de réception"
|
||||
className={sectionClassName}
|
||||
>
|
||||
<SettingsSection title="Type de boîte de réception" variant={variant}>
|
||||
{INBOX_OPTIONS.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
@ -282,7 +292,7 @@ export function MailSettingsFields({
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Volet de lecture" className={sectionClassName}>
|
||||
<SettingsSection title="Volet de lecture" variant={variant}>
|
||||
{READING_PANE_OPTIONS.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
@ -298,7 +308,12 @@ export function MailSettingsFields({
|
||||
))}
|
||||
</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" />
|
||||
<QuickSettingsCheckbox
|
||||
label="Mode Conversation"
|
||||
@ -309,4 +324,10 @@ export function MailSettingsFields({
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isPage) {
|
||||
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ export function MailToaster() {
|
||||
}
|
||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
richColors
|
||||
closeButton
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -67,7 +67,6 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
toggleChipLast7Days,
|
||||
toggleChipFromMe,
|
||||
resetChips,
|
||||
reset,
|
||||
} = useMailSearchStore.getState()
|
||||
|
||||
const [advancedMode, setAdvancedMode] = useState(false)
|
||||
@ -79,10 +78,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
setAdvancedMode(false)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
} else {
|
||||
reset()
|
||||
resetChips()
|
||||
setAdvancedMode(false)
|
||||
}
|
||||
}, [open, initialQuery, setInputValue, reset])
|
||||
}, [open, initialQuery, setInputValue, resetChips])
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||
|
||||
@ -194,17 +194,20 @@ export function ThemeThumbnailIcon() {
|
||||
function ThemeModePreviewFrame({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-md border border-border",
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: "#ffffff" }}
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@ -226,15 +229,18 @@ function MailChromePreview({
|
||||
}) {
|
||||
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={cn("w-[24%] shrink-0", sidebarBg)} />
|
||||
<div className={cn("flex min-w-0 flex-1 flex-col p-0.5", listBg)}>
|
||||
<div className={cn("h-px w-full", lineBg)} />
|
||||
<div className={cn("mt-0.5 h-px w-3/4", lineBg)} />
|
||||
<div className={cn("mt-0.5 h-px w-1/2", lineBg)} />
|
||||
<div className="w-[24%] shrink-0" style={{ backgroundColor: sidebarBg }} />
|
||||
<div
|
||||
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||
style={{ backgroundColor: listBg }}
|
||||
>
|
||||
<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 className={cn("w-[30%] shrink-0", contentBg)} />
|
||||
<div className="w-[30%] shrink-0" style={{ backgroundColor: contentBg }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -242,13 +248,16 @@ function MailChromePreview({
|
||||
|
||||
export function ThemeLightPreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
||||
<ThemeModePreviewFrame
|
||||
className={cn("h-12", className)}
|
||||
data-mail-theme-preview="light"
|
||||
>
|
||||
<MailChromePreview
|
||||
headerBg="bg-white"
|
||||
sidebarBg="bg-[#f1f3f4]"
|
||||
listBg="bg-white"
|
||||
contentBg="bg-[#e8f0fe]"
|
||||
lineBg="bg-[#dadce0]"
|
||||
headerBg="#ffffff"
|
||||
sidebarBg="#f1f3f4"
|
||||
listBg="#ffffff"
|
||||
contentBg="#e8f0fe"
|
||||
lineBg="#dadce0"
|
||||
/>
|
||||
</ThemeModePreviewFrame>
|
||||
)
|
||||
@ -256,13 +265,17 @@ export function ThemeLightPreview({ className }: { className?: string }) {
|
||||
|
||||
export function ThemeDarkPreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
||||
<ThemeModePreviewFrame
|
||||
className={cn("h-12", className)}
|
||||
data-mail-theme-preview="dark"
|
||||
style={{ backgroundColor: "#202124" }}
|
||||
>
|
||||
<MailChromePreview
|
||||
headerBg="bg-[#202124]"
|
||||
sidebarBg="bg-[#3c4043]"
|
||||
listBg="bg-[#202124]"
|
||||
contentBg="bg-[#394457]"
|
||||
lineBg="bg-[#5f6368]"
|
||||
headerBg="#202124"
|
||||
sidebarBg="#3c4043"
|
||||
listBg="#202124"
|
||||
contentBg="#394457"
|
||||
lineBg="#5f6368"
|
||||
/>
|
||||
</ThemeModePreviewFrame>
|
||||
)
|
||||
@ -270,25 +283,35 @@ export function ThemeDarkPreview({ className }: { className?: string }) {
|
||||
|
||||
export function ThemeSystemPreview({ className }: { className?: string }) {
|
||||
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 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="w-[24%] shrink-0 bg-[#f1f3f4]" />
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-white p-0.5">
|
||||
<div className="h-px w-full bg-[#dadce0]" />
|
||||
<div className="mt-0.5 h-px w-3/4 bg-[#dadce0]" />
|
||||
<div className="w-[24%] shrink-0" style={{ backgroundColor: "#f1f3f4" }} />
|
||||
<div
|
||||
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||
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 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="w-[24%] shrink-0 bg-[#3c4043]" />
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-[#202124] p-0.5">
|
||||
<div className="h-px w-full bg-[#5f6368]" />
|
||||
<div className="mt-0.5 h-px w-3/4 bg-[#5f6368]" />
|
||||
<div className="w-[24%] shrink-0" style={{ backgroundColor: "#3c4043" }} />
|
||||
<div
|
||||
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||
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>
|
||||
|
||||
@ -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'ai copié le token
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -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'intégralité de l'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>
|
||||
)
|
||||
}
|
||||
@ -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'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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
245
components/gmail/settings/automation/api-tokens-panel.tsx
Normal file
245
components/gmail/settings/automation/api-tokens-panel.tsx
Normal 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'est affiché qu'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'autres
|
||||
tokens via l'API — réservez-la aux agents d'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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/api/hooks/use-contact-discovery"
|
||||
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
||||
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PRIMARY_BTN_CLASS,
|
||||
@ -92,7 +93,7 @@ export function LLMProvidersPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
|
||||
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||
@ -100,130 +101,133 @@ export function LLMProvidersPanel() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{draft.providers.map((provider, index) => (
|
||||
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeProvider(index)}
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
{draft.providers.map((provider, index) => (
|
||||
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeProvider(index)}
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<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 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>
|
||||
<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"
|
||||
/>
|
||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||
<Select
|
||||
value={draft.default_provider_id}
|
||||
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'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">URL de base</Label>
|
||||
<Label className="text-xs">Modèle LLM</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"
|
||||
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>
|
||||
))}
|
||||
</AutomationTabMasonry>
|
||||
|
||||
<Button variant="outline" onClick={addProvider}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ajouter un fournisseur
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||
<Select
|
||||
value={draft.default_provider_id}
|
||||
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'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 className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={addProvider}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ajouter un fournisseur
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||
>
|
||||
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||
>
|
||||
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -72,7 +72,7 @@ export function SearchProvidersPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
|
||||
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -12,6 +12,8 @@ import {
|
||||
} from "@/lib/api/hooks/use-mail-automation-queries"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
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() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
@ -28,57 +30,62 @@ export function WebhooksPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||
<Label className="text-xs">Nouveau webhook</Label>
|
||||
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="body_template JSON"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
||||
onClick={() =>
|
||||
createWebhook.mutate({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
method: "POST",
|
||||
body_template: template,
|
||||
})
|
||||
}
|
||||
>
|
||||
Créer le webhook
|
||||
</Button>
|
||||
</div>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<WebhookTemplateVariablesPanel />
|
||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||
<Label className="text-xs">Nouveau webhook</Label>
|
||||
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="body_template JSON"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
||||
onClick={() =>
|
||||
createWebhook.mutate({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
method: "POST",
|
||||
body_template: template,
|
||||
})
|
||||
}
|
||||
>
|
||||
Créer le webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showInitialLoad ? null : webhooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{webhooks.map((hook) => (
|
||||
<li
|
||||
key={hook.id}
|
||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<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)}
|
||||
{showInitialLoad ? (
|
||||
<p className="text-sm text-muted-foreground">Chargement…</p>
|
||||
) : webhooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{webhooks.map((hook) => (
|
||||
<li
|
||||
key={hook.id}
|
||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<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" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AutomationTabMasonry>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
33
components/gmail/settings/mail-settings-header.tsx
Normal file
33
components/gmail/settings/mail-settings-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -2,40 +2,34 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isMailSettingsNavActive,
|
||||
isMailSettingsWideLayoutPath,
|
||||
MAIL_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 }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-background">
|
||||
<header className="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<Link
|
||||
href="/mail/inbox"
|
||||
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'affichage et des automatisations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
data-mail-settings-app
|
||||
className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
|
||||
>
|
||||
<MailSettingsHeader />
|
||||
|
||||
<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">
|
||||
{MAIL_SETTINGS_NAV.map((item) => {
|
||||
const active = isMailSettingsNavActive(pathname, item)
|
||||
@ -44,17 +38,30 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
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
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-accent/50"
|
||||
? "bg-mail-nav-selected"
|
||||
: "hover:bg-mail-nav-hover"
|
||||
)}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0 opacity-70" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{item.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
<Icon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0 opacity-70",
|
||||
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}
|
||||
</span>
|
||||
</span>
|
||||
@ -64,41 +71,50 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<nav
|
||||
className="shrink-0 border-b border-border bg-muted/20 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) => {
|
||||
const active = isMailSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center rounded-lg transition-colors",
|
||||
active
|
||||
? "gap-2 bg-accent px-3 py-2 text-accent-foreground"
|
||||
: "size-9 justify-center text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 opacity-70" />
|
||||
{active ? (
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
<div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
|
||||
<div data-mail-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
|
||||
<nav
|
||||
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) => {
|
||||
const active = isMailSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center rounded-lg",
|
||||
active
|
||||
? 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 ? (
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-3xl",
|
||||
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
205
components/gmail/settings/mail-settings-search-bar.tsx
Normal file
205
components/gmail/settings/mail-settings-search-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
||||
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-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() {
|
||||
return (
|
||||
@ -37,10 +37,7 @@ export function AutomationSettingsSection() {
|
||||
<SearchProvidersPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="tokens" className="mt-4">
|
||||
<SettingsComingSoon
|
||||
title="Tokens API agents"
|
||||
description="Créez des jetons fine-grained pour agents IA (lecture partielle, envoi, catégorisation)."
|
||||
/>
|
||||
<ApiTokensPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
|
||||
@ -21,6 +21,11 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
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 { useMailAccounts } from "@/lib/api/hooks/use-mail-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()} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<SignatureLibrary
|
||||
signatures={signatures}
|
||||
showInitialLoad={showInitialLoad}
|
||||
/>
|
||||
<IdentitySignatureAssignments signatures={signatures} />
|
||||
<div className={cn("space-y-6 lg:space-y-0", MAIL_SETTINGS_PAGE_MASONRY_CLASS)}>
|
||||
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
|
||||
<SignatureLibrary
|
||||
signatures={signatures}
|
||||
showInitialLoad={showInitialLoad}
|
||||
/>
|
||||
</div>
|
||||
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
|
||||
<IdentitySignatureAssignments signatures={signatures} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -344,15 +353,15 @@ function IdentitySignatureRow({
|
||||
: NONE_SIGNATURE
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{identity.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{identity.email}</p>
|
||||
<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-[10rem] max-w-full flex-1">
|
||||
<p className="text-sm font-medium">{identity.name}</p>
|
||||
<p className="text-xs text-muted-foreground break-all">{identity.email}</p>
|
||||
{identity.is_default ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
|
||||
) : null}
|
||||
</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>
|
||||
<Select
|
||||
value={current}
|
||||
|
||||
166
components/mail/mail-drive-folder-picker.tsx
Normal file
166
components/mail/mail-drive-folder-picker.tsx
Normal 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
Loading…
Reference in New Issue
Block a user