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
|
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||||
OIDC_CLIENT_SECRET=changeme
|
OIDC_CLIENT_SECRET=changeme
|
||||||
|
|
||||||
|
# OnlyOffice Document Server (UltiDrive editor)
|
||||||
|
NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = suitePageMetadata({
|
||||||
title: "Contacts - Ultimail",
|
app: "contacts",
|
||||||
}
|
absoluteTitle: true,
|
||||||
|
title: "Contacts - Ulti Suite",
|
||||||
|
})
|
||||||
|
|
||||||
export default function ContactsLayout({
|
export default function ContactsLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
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 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@import '../styles/onlyoffice-theme.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@ -63,6 +64,11 @@
|
|||||||
--mail-list-chip-text: #3c4043;
|
--mail-list-chip-text: #3c4043;
|
||||||
--mail-list-chip-muted: #f1f3f4;
|
--mail-list-chip-muted: #f1f3f4;
|
||||||
--mail-row-checkbox-border: #c2c2c2;
|
--mail-row-checkbox-border: #c2c2c2;
|
||||||
|
--drive-canvas: var(--app-canvas);
|
||||||
|
--drive-sidebar-foreground: var(--mail-text);
|
||||||
|
--drive-surface: var(--mail-surface);
|
||||||
|
--drive-toolbar: var(--mail-surface-elevated);
|
||||||
|
--suite-surface-elevated: var(--mail-surface-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -91,6 +97,11 @@
|
|||||||
--mail-list-chip-text: #e8eaed;
|
--mail-list-chip-text: #e8eaed;
|
||||||
--mail-list-chip-muted: #3c4043;
|
--mail-list-chip-muted: #3c4043;
|
||||||
--mail-row-checkbox-border: #9aa0a6;
|
--mail-row-checkbox-border: #9aa0a6;
|
||||||
|
--drive-canvas: var(--app-canvas);
|
||||||
|
--drive-sidebar-foreground: var(--mail-text);
|
||||||
|
--drive-surface: var(--mail-surface);
|
||||||
|
--drive-toolbar: var(--mail-surface-elevated);
|
||||||
|
--suite-surface-elevated: var(--mail-surface-elevated);
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
@ -186,6 +197,13 @@
|
|||||||
--color-mail-surface: var(--mail-surface);
|
--color-mail-surface: var(--mail-surface);
|
||||||
--color-mail-surface-elevated: var(--mail-surface-elevated);
|
--color-mail-surface-elevated: var(--mail-surface-elevated);
|
||||||
--color-mail-surface-muted: var(--mail-surface-muted);
|
--color-mail-surface-muted: var(--mail-surface-muted);
|
||||||
|
--color-mail-text: var(--mail-text);
|
||||||
|
--color-mail-text-strong: var(--mail-text-strong);
|
||||||
|
--color-mail-text-muted: var(--mail-text-muted);
|
||||||
|
--color-mail-active: var(--mail-active);
|
||||||
|
--color-mail-nav-selected: var(--mail-nav-selected);
|
||||||
|
--color-mail-nav-selected-fg: var(--mail-nav-selected-fg);
|
||||||
|
--color-mail-nav-hover: var(--mail-nav-hover);
|
||||||
--color-mail-border: var(--mail-border);
|
--color-mail-border: var(--mail-border);
|
||||||
--color-mail-border-subtle: var(--mail-border-subtle);
|
--color-mail-border-subtle: var(--mail-border-subtle);
|
||||||
--color-mail-invitation: var(--mail-invitation);
|
--color-mail-invitation: var(--mail-invitation);
|
||||||
@ -194,6 +212,9 @@
|
|||||||
--color-mail-list-chip-text: var(--mail-list-chip-text);
|
--color-mail-list-chip-text: var(--mail-list-chip-text);
|
||||||
--color-mail-list-chip-muted: var(--mail-list-chip-muted);
|
--color-mail-list-chip-muted: var(--mail-list-chip-muted);
|
||||||
--color-mail-row-checkbox-border: var(--mail-row-checkbox-border);
|
--color-mail-row-checkbox-border: var(--mail-row-checkbox-border);
|
||||||
|
--color-drive-canvas: var(--drive-canvas);
|
||||||
|
--color-drive-surface: var(--drive-surface);
|
||||||
|
--color-drive-sidebar-foreground: var(--drive-sidebar-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@ -633,6 +654,61 @@ html[data-mail-background]:not([data-mail-background='none'])
|
|||||||
background-color: color-mix(in srgb, var(--mail-surface) 88%, transparent) !important;
|
background-color: color-mix(in srgb, var(--mail-surface) 88%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drive : pas de fond décoratif mail — surfaces opaques (carte arrondie + chrome). */
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app].ultimail-app {
|
||||||
|
background-color: var(--app-canvas) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-app-canvas) {
|
||||||
|
background-color: var(--app-canvas) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface, .bg-white) {
|
||||||
|
background-color: var(--mail-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-drive-app] :where(.bg-mail-surface-elevated) {
|
||||||
|
background-color: var(--mail-surface-elevated) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contacts : pas de fond décoratif mail — surfaces opaques. */
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app].ultimail-app {
|
||||||
|
background-color: var(--app-canvas) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-app-canvas) {
|
||||||
|
background-color: var(--app-canvas) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface, .bg-white) {
|
||||||
|
background-color: var(--mail-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-elevated) {
|
||||||
|
background-color: var(--mail-surface-elevated) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app] :where(.bg-mail-surface-muted) {
|
||||||
|
background-color: var(--mail-surface-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app {
|
||||||
|
background-color: var(--app-canvas) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
|
[data-mail-settings-app]
|
||||||
|
[data-mail-settings-sidebar] {
|
||||||
|
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
|
[data-mail-settings-app]
|
||||||
|
:where([data-mail-settings-main]) {
|
||||||
|
background-color: var(--mail-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.ultimail-app {
|
.ultimail-app {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
@ -744,6 +820,22 @@ html.dark[data-mail-background]:not([data-mail-background='none'])
|
|||||||
/* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
|
/* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
|
||||||
html.dark .ultimail-app {
|
html.dark .ultimail-app {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
/* Tokens shadcn → gris mail (cards réglages, popovers, champs). */
|
||||||
|
--background: var(--app-canvas);
|
||||||
|
--foreground: var(--mail-text);
|
||||||
|
--card: var(--mail-surface-elevated);
|
||||||
|
--card-foreground: var(--mail-text);
|
||||||
|
--popover: var(--mail-surface-elevated);
|
||||||
|
--popover-foreground: var(--mail-text);
|
||||||
|
--secondary: var(--mail-surface-muted);
|
||||||
|
--secondary-foreground: var(--mail-text);
|
||||||
|
--muted: var(--mail-surface-muted);
|
||||||
|
--muted-foreground: var(--mail-text-muted);
|
||||||
|
--accent: var(--mail-nav-hover);
|
||||||
|
--accent-foreground: var(--mail-text);
|
||||||
|
--border: var(--mail-border-subtle);
|
||||||
|
--input: var(--mail-border-subtle);
|
||||||
|
--ring: var(--mail-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .ultimail-app :where(.bg-white) {
|
html.dark .ultimail-app :where(.bg-white) {
|
||||||
@ -767,7 +859,7 @@ html.dark .ultimail-app :where(.bg-\[\#e8f0fe\]) {
|
|||||||
background-color: var(--mail-active) !important;
|
background-color: var(--mail-active) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .ultimail-app :where([class*='bg-white/']) {
|
html.dark .ultimail-app :where(.bg-white\/80, .bg-white\/90, .bg-white\/95) {
|
||||||
background-color: color-mix(in srgb, var(--mail-surface) 82%, transparent) !important;
|
background-color: color-mix(in srgb, var(--mail-surface) 82%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -831,6 +923,28 @@ html.dark [data-slot='menubar-content'] {
|
|||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */
|
||||||
|
html.dark [data-drive-menu-surface],
|
||||||
|
html.dark [data-contacts-menu-surface] {
|
||||||
|
background-color: var(--mail-surface-elevated) !important;
|
||||||
|
color: var(--mail-text) !important;
|
||||||
|
border-color: var(--mail-border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item']:focus,
|
||||||
|
html.dark [data-drive-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted],
|
||||||
|
html.dark [data-drive-menu-surface] [data-slot='context-menu-item']:focus,
|
||||||
|
html.dark [data-drive-menu-surface] [data-slot='context-menu-item'][data-highlighted],
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item']:focus,
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-item'][data-highlighted],
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger']:focus,
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='dropdown-menu-sub-trigger'][data-state='open'],
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='select-item']:focus,
|
||||||
|
html.dark [data-contacts-menu-surface] [data-slot='select-item'][data-highlighted] {
|
||||||
|
background-color: var(--mail-nav-hover) !important;
|
||||||
|
color: var(--mail-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark [data-slot='dropdown-menu-item']:focus,
|
html.dark [data-slot='dropdown-menu-item']:focus,
|
||||||
html.dark [data-slot='dropdown-menu-item'][data-highlighted],
|
html.dark [data-slot='dropdown-menu-item'][data-highlighted],
|
||||||
html.dark [data-slot='dropdown-menu-sub-trigger']:focus,
|
html.dark [data-slot='dropdown-menu-sub-trigger']:focus,
|
||||||
@ -999,3 +1113,18 @@ html.dark :where([data-contacts-panel] .hover\:bg-gray-100:hover, [data-contacts
|
|||||||
html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) {
|
html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) {
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
|
||||||
|
html.dark .ultimail-app :where(.bg-background) {
|
||||||
|
background-color: var(--mail-surface-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg-muted\/40) {
|
||||||
|
background-color: color-mix(in srgb, var(--mail-surface-muted) 72%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) {
|
||||||
|
background-color: var(--mail-surface-muted) !important;
|
||||||
|
border-color: var(--mail-border-subtle) !important;
|
||||||
|
color: var(--mail-text) !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -7,15 +7,12 @@ import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
|||||||
import { QueryProvider } from '@/lib/api/query-provider'
|
import { QueryProvider } from '@/lib/api/query-provider'
|
||||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||||
import { MailToaster } from '@/components/gmail/mail-toaster'
|
import { MailToaster } from '@/components/gmail/mail-toaster'
|
||||||
|
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = suiteRootMetadata()
|
||||||
title: 'Ultimail',
|
|
||||||
description: 'Interface client mail Ultimail (clone UI) construite avec React',
|
|
||||||
generator: 'v0.app',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
|
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { LoginChrome } from "@/components/auth/login-chrome"
|
import { LoginChrome } from "@/components/auth/login-chrome"
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
|
export const metadata: Metadata = suitePageMetadata({
|
||||||
|
app: "suite",
|
||||||
|
title: "Connexion",
|
||||||
|
})
|
||||||
|
|
||||||
export default function LoginLayout({
|
export default function LoginLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@ -1,4 +1,12 @@
|
|||||||
import { MailAppShell } from "./mail-app-shell"
|
import { MailAppShell } from "./mail-app-shell"
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { suitePageMetadata, MAIL_INBOX_DOCUMENT_TITLE } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
|
export const metadata: Metadata = suitePageMetadata({
|
||||||
|
app: "mail",
|
||||||
|
absoluteTitle: true,
|
||||||
|
title: MAIL_INBOX_DOCUMENT_TITLE,
|
||||||
|
})
|
||||||
|
|
||||||
export default function MailLayout({
|
export default function MailLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { useIsXs } from "@/hooks/use-xs"
|
|||||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
||||||
import { useMailRoute } from "@/hooks/use-mail-route"
|
import { useMailRoute } from "@/hooks/use-mail-route"
|
||||||
|
import { parseSearchParams } from "@/lib/mail-search/search-params"
|
||||||
|
import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
|
||||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||||
@ -27,6 +29,7 @@ import { ScheduledMailProvider } from "@/lib/scheduled-mail-context"
|
|||||||
import { ComposeModalManager } from "@/components/gmail/compose-modal"
|
import { ComposeModalManager } from "@/components/gmail/compose-modal"
|
||||||
import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
|
import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
|
||||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||||
|
import { MailDocumentTitle } from "@/components/gmail/mail-document-title"
|
||||||
import { useMailStore } from "@/lib/stores/mail-store"
|
import { useMailStore } from "@/lib/stores/mail-store"
|
||||||
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||||
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
||||||
@ -41,6 +44,7 @@ import { MailSignaturesSync } from "@/components/gmail/mail-signatures-sync"
|
|||||||
import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge"
|
import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge"
|
||||||
import { useWebSocket } from "@/lib/api/ws"
|
import { useWebSocket } from "@/lib/api/ws"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
|
import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
|
||||||
|
|
||||||
const MAIL_SETTINGS_PATH = "/mail/settings"
|
const MAIL_SETTINGS_PATH = "/mail/settings"
|
||||||
|
|
||||||
@ -52,6 +56,10 @@ function MailAppInner() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { route, navigateRoute, searchParams: currentSearchParams } =
|
const { route, navigateRoute, searchParams: currentSearchParams } =
|
||||||
useMailRoute()
|
useMailRoute()
|
||||||
|
const activeSearchQuery =
|
||||||
|
route.folderId === "search"
|
||||||
|
? searchParamsToDisplayQuery(parseSearchParams(currentSearchParams))
|
||||||
|
: ""
|
||||||
|
|
||||||
const isXs = useIsXs()
|
const isXs = useIsXs()
|
||||||
const touchNav = useTouchNav()
|
const touchNav = useTouchNav()
|
||||||
@ -108,6 +116,7 @@ function MailAppInner() {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<MailDocumentTitle />
|
||||||
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||||
{!splitView ? (
|
{!splitView ? (
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
@ -191,14 +200,14 @@ function MailAppInner() {
|
|||||||
}
|
}
|
||||||
xsViewChrome={xsViewChrome}
|
xsViewChrome={xsViewChrome}
|
||||||
onOpenSearch={() => setMobileSearchOpen(true)}
|
onOpenSearch={() => setMobileSearchOpen(true)}
|
||||||
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
searchQuery={activeSearchQuery}
|
||||||
onClearSearch={() => router.push("/mail/inbox")}
|
onClearSearch={() => router.push("/mail/inbox")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<MobileSearchOverlay
|
<MobileSearchOverlay
|
||||||
open={mobileSearchOpen}
|
open={mobileSearchOpen}
|
||||||
onClose={() => setMobileSearchOpen(false)}
|
onClose={() => setMobileSearchOpen(false)}
|
||||||
initialQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}
|
initialQuery={activeSearchQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarNavProvider>
|
</SidebarNavProvider>
|
||||||
@ -260,6 +269,7 @@ export function MailAppShell({
|
|||||||
<QuickSettingsRoot />
|
<QuickSettingsRoot />
|
||||||
<MoveDragIndicator />
|
<MoveDragIndicator />
|
||||||
<ComposeModalManager />
|
<ComposeModalManager />
|
||||||
|
<FilePreviewDialog />
|
||||||
</EmailDragProvider>
|
</EmailDragProvider>
|
||||||
</ScheduledMailProvider>
|
</ScheduledMailProvider>
|
||||||
</ComposeProvider>
|
</ComposeProvider>
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout"
|
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout"
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
|
export const metadata: Metadata = suitePageMetadata({
|
||||||
|
app: "mail",
|
||||||
|
title: "Réglages",
|
||||||
|
})
|
||||||
|
|
||||||
export default function MailSettingsRootLayout({
|
export default function MailSettingsRootLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useAuthStore } from "@/lib/api/auth-store"
|
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc-config"
|
import { isOidcConfigured } from "@/lib/auth/oidc-config"
|
||||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ const REFRESH_LEAD_MS = 5 * 60 * 1000
|
|||||||
const REFRESH_CHECK_MS = 60 * 1000
|
const REFRESH_CHECK_MS = 60 * 1000
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
|
if (pathname.startsWith("/drive/s/")) return true
|
||||||
return PUBLIC_PREFIXES.some(
|
return PUBLIC_PREFIXES.some(
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
||||||
)
|
)
|
||||||
@ -159,7 +160,10 @@ export function useAuthLogout() {
|
|||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||||
logout()
|
logout()
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("ultimail-auth")
|
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
|
for (const legacy of LEGACY_AUTH_KEYS) {
|
||||||
|
localStorage.removeItem(legacy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
router.replace("/login")
|
router.replace("/login")
|
||||||
}
|
}
|
||||||
|
|||||||
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_MUTED_TEXT,
|
||||||
CONTACTS_PAGE_ICON_BTN_CLASS,
|
CONTACTS_PAGE_ICON_BTN_CLASS,
|
||||||
CONTACTS_PAGE_SAVE_BTN_CLASS,
|
CONTACTS_PAGE_SAVE_BTN_CLASS,
|
||||||
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||||
CONTACTS_PANEL_CARD_CLASS,
|
CONTACTS_PANEL_CARD_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||||
@ -329,7 +330,11 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
<Plus className="h-3 w-3" /> Libellé
|
<Plus className="h-3 w-3" /> Libellé
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-52 p-1" align="center">
|
<PopoverContent
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={cn("w-52 p-1", CONTACTS_MENU_SURFACE_CLASS)}
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p>
|
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p>
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{availableLabels.map((row) => {
|
{availableLabels.map((row) => {
|
||||||
@ -562,7 +567,7 @@ function CompactSelect({ value, onValueChange, options, placeholder }: { value:
|
|||||||
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
||||||
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
|
||||||
{options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
{options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@ -108,8 +108,12 @@ export function ContactsAppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-contacts-app
|
||||||
data-contacts-panel
|
data-contacts-panel
|
||||||
className={cn("relative flex h-dvh max-h-dvh overflow-hidden", CONTACTS_SHELL_CLASS)}
|
className={cn(
|
||||||
|
"ultimail-app relative flex h-dvh max-h-dvh overflow-hidden",
|
||||||
|
CONTACTS_SHELL_CLASS,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && sidebarOpen && (
|
{isMobile && sidebarOpen && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
} from "@/lib/contacts/bulk-edit-fields"
|
} from "@/lib/contacts/bulk-edit-fields"
|
||||||
import {
|
import {
|
||||||
CONTACTS_FIELD_CLASS,
|
CONTACTS_FIELD_CLASS,
|
||||||
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_PRIMARY_BTN_CLASS,
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
@ -107,7 +108,7 @@ export function ContactsBulkEditDialog({
|
|||||||
<SelectTrigger className={CONTACTS_FIELD_CLASS}>
|
<SelectTrigger className={CONTACTS_FIELD_CLASS}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
|
||||||
{CONTACT_BULK_EDIT_FIELDS.map((f) => (
|
{CONTACT_BULK_EDIT_FIELDS.map((f) => (
|
||||||
<SelectItem key={f.id} value={f.id}>
|
<SelectItem key={f.id} value={f.id}>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Menu, Search, X } from "lucide-react"
|
import { Menu, Search, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
|
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||||
import {
|
import {
|
||||||
CONTACTS_ICON_BTN_CLASS,
|
CONTACTS_ICON_BTN_CLASS,
|
||||||
CONTACTS_SEARCH_BAR_CLASS,
|
CONTACTS_SEARCH_BAR_CLASS,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import {
|
import {
|
||||||
CONTACTS_CREATE_BTN_CLASS,
|
CONTACTS_CREATE_BTN_CLASS,
|
||||||
CONTACTS_FIELD_CLASS,
|
CONTACTS_FIELD_CLASS,
|
||||||
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_NAV_ACTIVE_CLASS,
|
CONTACTS_NAV_ACTIVE_CLASS,
|
||||||
CONTACTS_NAV_ICON_MUTED,
|
CONTACTS_NAV_ICON_MUTED,
|
||||||
@ -34,12 +35,13 @@ import {
|
|||||||
CONTACTS_CREATE_BTN_LABEL_CLASS,
|
CONTACTS_CREATE_BTN_LABEL_CLASS,
|
||||||
CONTACTS_SIDEBAR_CLASS,
|
CONTACTS_SIDEBAR_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
import { CONTACTS_MENU_SURFACE_CLASS } from "@/lib/contacts-chrome-classes"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
|
import { useDiscoveryCounts, useVisibleEnrichmentSuggestions } from "@/lib/api/hooks/use-contact-discovery"
|
||||||
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
import { findDuplicatePairs } from "@/lib/contacts/duplicate-detection"
|
||||||
import { useNavStore } from "@/lib/stores/nav-store"
|
import { useNavStore } from "@/lib/stores/nav-store"
|
||||||
|
import { ContactsPanelLogo } from "@/components/gmail/contacts/contacts-panel-logo"
|
||||||
import type { ContactsPageView } from "./contacts-app-shell"
|
import type { ContactsPageView } from "./contacts-app-shell"
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
@ -146,15 +148,10 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<ContactsPanelLogo
|
||||||
type="button"
|
|
||||||
onClick={onHome ?? (() => onNavigate("contacts"))}
|
onClick={onHome ?? (() => onNavigate("contacts"))}
|
||||||
className="flex min-w-0 items-center gap-2 rounded-full px-1 py-0.5 transition-colors hover:bg-accent"
|
titleClassName={cn("text-[22px] font-normal", CONTACTS_HEADING_TEXT)}
|
||||||
aria-label="Liste des contacts"
|
/>
|
||||||
>
|
|
||||||
<Users className={cn("h-6 w-6", CONTACTS_NAV_ICON_MUTED)} />
|
|
||||||
<span className={cn("text-[22px] font-normal", CONTACTS_MUTED_TEXT)}>Contacts</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create button */}
|
{/* Create button */}
|
||||||
@ -170,7 +167,11 @@ export function ContactsSidebar({
|
|||||||
<ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
|
<ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className={cn("w-56", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={cn("w-56", CONTACTS_MENU_SURFACE_CLASS)}
|
||||||
|
>
|
||||||
<DropdownMenuItem onClick={onCreateContact}>
|
<DropdownMenuItem onClick={onCreateContact}>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Créer un contact
|
Créer un contact
|
||||||
|
|||||||
@ -41,7 +41,7 @@ import {
|
|||||||
CONTACTS_TABLE_TOOLBAR_CLASS,
|
CONTACTS_TABLE_TOOLBAR_CLASS,
|
||||||
CONTACTS_TABLE_STICKY_HEAD_CLASS,
|
CONTACTS_TABLE_STICKY_HEAD_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
|
import { CONTACTS_MENU_SURFACE_CLASS } from "@/lib/contacts-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
|
import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state"
|
||||||
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
|
import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block"
|
||||||
@ -359,8 +359,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
|
data-contacts-menu-surface
|
||||||
className={cn(
|
className={cn(
|
||||||
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -410,7 +411,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
<Download className="h-5 w-5" />
|
<Download className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className={cn("w-52", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={cn("w-52", CONTACTS_MENU_SURFACE_CLASS)}
|
||||||
|
>
|
||||||
<DropdownMenuItem onClick={handleExportVcf}>
|
<DropdownMenuItem onClick={handleExportVcf}>
|
||||||
Exporter au format vCard (.vcf)
|
Exporter au format vCard (.vcf)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -444,7 +449,11 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
<MoreVertical className="h-5 w-5" />
|
<MoreVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className={cn("w-56 overflow-visible", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={cn("w-56 overflow-visible", CONTACTS_MENU_SURFACE_CLASS)}
|
||||||
|
>
|
||||||
{selectionCount > 0 && (
|
{selectionCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
@ -453,8 +462,9 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
|||||||
Ajouter / Retirer des libellés
|
Ajouter / Retirer des libellés
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
|
data-contacts-menu-surface
|
||||||
className={cn(
|
className={cn(
|
||||||
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
|
|||||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||||
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import {
|
import {
|
||||||
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_PAGE_BANNER_CLASS,
|
CONTACTS_PAGE_BANNER_CLASS,
|
||||||
@ -97,7 +98,11 @@ export function TrashView() {
|
|||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={CONTACTS_MENU_SURFACE_CLASS}
|
||||||
|
>
|
||||||
<DropdownMenuItem onClick={() => restoreContact(contact.id)}>
|
<DropdownMenuItem onClick={() => restoreContact(contact.id)}>
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
Restaurer
|
Restaurer
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import { useNavStore } from "@/lib/stores/nav-store"
|
|||||||
import {
|
import {
|
||||||
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
|
||||||
CONTACTS_PANEL_CARD_CLASS,
|
CONTACTS_PANEL_CARD_CLASS,
|
||||||
|
CONTACTS_MENU_SURFACE_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
||||||
@ -473,7 +474,11 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
Libellé
|
Libellé
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-52 p-1" align="center">
|
<PopoverContent
|
||||||
|
data-contacts-menu-surface
|
||||||
|
className={cn("w-52 p-1", CONTACTS_MENU_SURFACE_CLASS)}
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
|
<p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
|
||||||
Libellés
|
Libellés
|
||||||
</p>
|
</p>
|
||||||
@ -941,7 +946,7 @@ function CompactSelect({
|
|||||||
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
<SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
|
||||||
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent data-contacts-menu-surface className={CONTACTS_MENU_SURFACE_CLASS}>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Users } from "lucide-react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||||
import {
|
import {
|
||||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
|
||||||
CONTACTS_PANEL_TITLE_CLASS,
|
CONTACTS_PANEL_TITLE_CLASS,
|
||||||
} from "@/lib/contacts-chrome-classes"
|
} from "@/lib/contacts-chrome-classes"
|
||||||
|
|
||||||
|
const CONTACTS_MARK_SRC = suitePublicAsset("/contacts-mark.svg")
|
||||||
|
|
||||||
type ContactsPanelLogoProps = {
|
type ContactsPanelLogoProps = {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
titleClassName?: string
|
||||||
|
markClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps) {
|
export function ContactsPanelLogo({
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
titleClassName = CONTACTS_PANEL_TITLE_CLASS,
|
||||||
|
markClassName = "h-8 w-8",
|
||||||
|
}: ContactsPanelLogoProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -23,8 +31,14 @@ export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps
|
|||||||
)}
|
)}
|
||||||
aria-label="Liste des contacts"
|
aria-label="Liste des contacts"
|
||||||
>
|
>
|
||||||
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
|
<img
|
||||||
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span>
|
src={CONTACTS_MARK_SRC}
|
||||||
|
alt=""
|
||||||
|
className={cn("shrink-0 object-contain", markClassName)}
|
||||||
|
draggable={false}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", titleClassName)}>Contacts</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,14 +62,25 @@ export function EmailListAttachmentRow({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{attachments.map((att, idx) => (
|
{attachments.map((att, idx) => (
|
||||||
<ListAttachmentChip key={`${emailId}-m-${idx}`} att={att} />
|
<ListAttachmentChip
|
||||||
|
key={`${emailId}-m-${idx}`}
|
||||||
|
att={att}
|
||||||
|
messageId={emailId}
|
||||||
|
attachments={attachments}
|
||||||
|
attachmentIndex={idx}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||||
{collapsed && attachments.length > 1 ? (
|
{collapsed && attachments.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
<ListAttachmentChip att={attachments[0]!} />
|
<ListAttachmentChip
|
||||||
|
att={attachments[0]!}
|
||||||
|
messageId={emailId}
|
||||||
|
attachments={attachments}
|
||||||
|
attachmentIndex={0}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 rounded-full border border-mail-list-chip-border bg-mail-list-chip-muted px-2.5 py-1 text-[13px] leading-snug text-muted-foreground"
|
className="shrink-0 rounded-full border border-mail-list-chip-border bg-mail-list-chip-muted px-2.5 py-1 text-[13px] leading-snug text-muted-foreground"
|
||||||
title={othersTitle}
|
title={othersTitle}
|
||||||
@ -79,7 +90,13 @@ export function EmailListAttachmentRow({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
attachments.map((att, idx) => (
|
attachments.map((att, idx) => (
|
||||||
<ListAttachmentChip key={`${emailId}-v-${idx}`} att={att} />
|
<ListAttachmentChip
|
||||||
|
key={`${emailId}-v-${idx}`}
|
||||||
|
att={att}
|
||||||
|
messageId={emailId}
|
||||||
|
attachments={attachments}
|
||||||
|
attachmentIndex={idx}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,44 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { File, Image as ImageIcon } from "lucide-react"
|
import { File, HardDrive, Image as ImageIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import type { EmailAttachment } from "@/lib/email-data"
|
import type { EmailAttachment } from "@/lib/email-data"
|
||||||
|
import {
|
||||||
|
mailAttachmentPreviewable,
|
||||||
|
openMailAttachmentsPreview,
|
||||||
|
} from "@/lib/mail/mail-attachment-preview"
|
||||||
|
|
||||||
|
export function ListAttachmentChip({
|
||||||
|
att,
|
||||||
|
messageId,
|
||||||
|
attachments,
|
||||||
|
attachmentIndex,
|
||||||
|
}: {
|
||||||
|
att: EmailAttachment
|
||||||
|
messageId: string
|
||||||
|
attachments: EmailAttachment[]
|
||||||
|
attachmentIndex: number
|
||||||
|
}) {
|
||||||
|
const onClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!att.id) {
|
||||||
|
toast.message("Pièce jointe non disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mailAttachmentPreviewable(att)) {
|
||||||
|
toast.message("Aperçu non disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openMailAttachmentsPreview(messageId, attachments, attachmentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAttachmentChip({ att }: { att: EmailAttachment }) {
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-mail-list-chip-border bg-transparent px-2.5 py-1 text-[13px] leading-snug text-mail-list-chip-text">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-mail-list-chip-border bg-transparent px-2.5 py-1 text-[13px] leading-snug text-mail-list-chip-text hover:bg-mail-list-chip-muted"
|
||||||
|
>
|
||||||
{att.kind === "pdf" ? (
|
{att.kind === "pdf" ? (
|
||||||
<File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden />
|
<File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden />
|
||||||
) : att.kind === "image" ? (
|
) : att.kind === "image" ? (
|
||||||
@ -18,6 +51,9 @@ export function ListAttachmentChip({ att }: { att: EmailAttachment }) {
|
|||||||
<File className="size-4 shrink-0 fill-muted-foreground" strokeWidth={0} aria-hidden />
|
<File className="size-4 shrink-0 fill-muted-foreground" strokeWidth={0} aria-hidden />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 truncate">{att.name}</span>
|
<span className="min-w-0 truncate">{att.name}</span>
|
||||||
</span>
|
{att.drivePath ? (
|
||||||
|
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import {
|
|||||||
buildSearchUrl,
|
buildSearchUrl,
|
||||||
type SearchParams,
|
type SearchParams,
|
||||||
} from "@/lib/mail-search/search-params"
|
} from "@/lib/mail-search/search-params"
|
||||||
|
import { searchParamsToMessageSearchFilter } from "@/lib/mail-search/search-filter"
|
||||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||||
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
|
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
|
||||||
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
|
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
|
||||||
@ -192,15 +193,7 @@ export function useEmailListData({
|
|||||||
|
|
||||||
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
||||||
if (!isSearchMode || !searchParams) return null
|
if (!isSearchMode || !searchParams) return null
|
||||||
return {
|
return searchParamsToMessageSearchFilter(searchParams, accountId)
|
||||||
q: searchParams.q || undefined,
|
|
||||||
from: searchParams.from || undefined,
|
|
||||||
label: searchParams.in !== "all" ? searchParams.in : undefined,
|
|
||||||
account_id: accountId,
|
|
||||||
date_from: searchParams.after || undefined,
|
|
||||||
date_to: searchParams.before || undefined,
|
|
||||||
has_attachment: searchParams.has.includes("attachment") ? true : undefined,
|
|
||||||
}
|
|
||||||
}, [isSearchMode, searchParams, accountId])
|
}, [isSearchMode, searchParams, accountId])
|
||||||
|
|
||||||
const messagesQuery = useMessages(
|
const messagesQuery = useMessages(
|
||||||
|
|||||||
@ -15,8 +15,9 @@ import {
|
|||||||
senderInitial,
|
senderInitial,
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||||
import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments"
|
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
|
||||||
import { attachmentsForEmailList } from "@/lib/attachment-display"
|
import { useRecoverMissingMessageAttachments } from "@/lib/api/hooks/use-recover-missing-message-attachments"
|
||||||
|
import { resolvePreviewAttachments } from "@/lib/attachment-display"
|
||||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||||
import {
|
import {
|
||||||
mailFlagIsRead,
|
mailFlagIsRead,
|
||||||
@ -69,6 +70,10 @@ import {
|
|||||||
formatApiMessageBody,
|
formatApiMessageBody,
|
||||||
plainTextBodyFallback,
|
plainTextBodyFallback,
|
||||||
} from "@/components/gmail/email-view/email-view-messages"
|
} from "@/components/gmail/email-view/email-view-messages"
|
||||||
|
import {
|
||||||
|
ConversationAttachmentsSection,
|
||||||
|
type ConversationAttachmentEntry,
|
||||||
|
} from "@/components/gmail/email-view/message-attachments"
|
||||||
|
|
||||||
function apiToLegacyEmail(
|
function apiToLegacyEmail(
|
||||||
msg: ApiMessageSummary,
|
msg: ApiMessageSummary,
|
||||||
@ -220,19 +225,69 @@ export function EmailView({
|
|||||||
[email, fullMessage, threadMessages]
|
[email, fullMessage, threadMessages]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: fetchedAttachments } = useMessageAttachments(
|
const allThreadMessages = useMemo((): ApiMessageFull[] => {
|
||||||
email.id,
|
const main: ApiMessageFull = fullMessage
|
||||||
email.has_attachments
|
? { ...email, ...fullMessage }
|
||||||
|
: { ...email }
|
||||||
|
return [...threadBefore, main, ...threadAfter]
|
||||||
|
}, [threadBefore, threadAfter, email, fullMessage])
|
||||||
|
|
||||||
|
// Thread API used to omit has_attachments; treat undefined as unknown (still fetch).
|
||||||
|
const attachmentMessageIds = useMemo(
|
||||||
|
() =>
|
||||||
|
allThreadMessages
|
||||||
|
.filter((m) => m.has_attachments !== false)
|
||||||
|
.map((m) => m.id),
|
||||||
|
[allThreadMessages]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { byId: attachmentsByMessageId, stateById: attachmentFetchStateById } =
|
||||||
|
useListMessageAttachments(attachmentMessageIds)
|
||||||
|
|
||||||
|
useRecoverMissingMessageAttachments(
|
||||||
|
allThreadMessages,
|
||||||
|
attachmentsByMessageId,
|
||||||
|
attachmentFetchStateById
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveMessageAttachments = useCallback(
|
||||||
|
(msg: Pick<ApiMessageSummary, "id" | "has_attachments">): EmailAttachment[] =>
|
||||||
|
resolvePreviewAttachments(
|
||||||
|
{ hasAttachment: msg.has_attachments },
|
||||||
|
attachmentsByMessageId.get(msg.id),
|
||||||
|
attachmentFetchStateById.get(msg.id) ?? "idle"
|
||||||
|
),
|
||||||
|
[attachmentsByMessageId, attachmentFetchStateById]
|
||||||
|
)
|
||||||
|
|
||||||
const mainMessageAttachments = useMemo(
|
const mainMessageAttachments = useMemo(
|
||||||
(): EmailAttachment[] =>
|
() => resolveMessageAttachments(email),
|
||||||
attachmentsForEmailList({
|
[resolveMessageAttachments, email]
|
||||||
hasAttachment: email.has_attachments,
|
|
||||||
attachments: fetchedAttachments,
|
|
||||||
}),
|
|
||||||
[email.has_attachments, fetchedAttachments]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const conversationAttachmentEntries = useMemo((): ConversationAttachmentEntry[] => {
|
||||||
|
return allThreadMessages
|
||||||
|
.map((msg) => {
|
||||||
|
const attachments = resolveMessageAttachments(msg)
|
||||||
|
if (attachments.length === 0) return null
|
||||||
|
const from = resolveMessageFrom(msg.from, {
|
||||||
|
selfEmails,
|
||||||
|
selfDisplayName,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
messageId: msg.id,
|
||||||
|
senderName: from.name,
|
||||||
|
attachments,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry): entry is ConversationAttachmentEntry => entry !== null)
|
||||||
|
}, [
|
||||||
|
allThreadMessages,
|
||||||
|
resolveMessageAttachments,
|
||||||
|
selfEmails,
|
||||||
|
selfDisplayName,
|
||||||
|
])
|
||||||
|
|
||||||
const { composeWindows } = useComposeWindows()
|
const { composeWindows } = useComposeWindows()
|
||||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||||
const { openComposeWithInitial } = useComposeActions()
|
const { openComposeWithInitial } = useComposeActions()
|
||||||
@ -382,6 +437,7 @@ export function EmailView({
|
|||||||
selfEmails={selfEmails}
|
selfEmails={selfEmails}
|
||||||
selfDisplayName={selfDisplayName}
|
selfDisplayName={selfDisplayName}
|
||||||
collapseQuotedReplies={otherThreadCount > 0}
|
collapseQuotedReplies={otherThreadCount > 0}
|
||||||
|
attachments={resolveMessageAttachments(msg)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -419,10 +475,15 @@ export function EmailView({
|
|||||||
selfEmails={selfEmails}
|
selfEmails={selfEmails}
|
||||||
selfDisplayName={selfDisplayName}
|
selfDisplayName={selfDisplayName}
|
||||||
collapseQuotedReplies={otherThreadCount > 0}
|
collapseQuotedReplies={otherThreadCount > 0}
|
||||||
|
attachments={resolveMessageAttachments(msg)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{otherThreadCount > 0 && conversationAttachmentEntries.length > 0 ? (
|
||||||
|
<ConversationAttachmentsSection entries={conversationAttachmentEntries} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showReplyForwardBar ? (
|
{showReplyForwardBar ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Star, Info } from "lucide-react"
|
import { Star, Info, Paperclip } from "lucide-react"
|
||||||
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
|
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -86,6 +86,7 @@ export function ThreadPriorMessage({
|
|||||||
selfEmails,
|
selfEmails,
|
||||||
selfDisplayName,
|
selfDisplayName,
|
||||||
collapseQuotedReplies = false,
|
collapseQuotedReplies = false,
|
||||||
|
attachments = [],
|
||||||
}: {
|
}: {
|
||||||
message: ApiMessageFull
|
message: ApiMessageFull
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
@ -96,6 +97,7 @@ export function ThreadPriorMessage({
|
|||||||
selfEmails: string[]
|
selfEmails: string[]
|
||||||
selfDisplayName?: string
|
selfDisplayName?: string
|
||||||
collapseQuotedReplies?: boolean
|
collapseQuotedReplies?: boolean
|
||||||
|
attachments?: EmailAttachment[]
|
||||||
}) {
|
}) {
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||||
const loadFull = isExpanded || detailsOpen
|
const loadFull = isExpanded || detailsOpen
|
||||||
@ -137,6 +139,13 @@ export function ThreadPriorMessage({
|
|||||||
message={message}
|
message={message}
|
||||||
senderName={resolved.name}
|
senderName={resolved.name}
|
||||||
senderEmail={resolved.email}
|
senderEmail={resolved.email}
|
||||||
|
attachmentCount={
|
||||||
|
attachments.length > 0
|
||||||
|
? attachments.length
|
||||||
|
: message.has_attachments
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -152,6 +161,7 @@ export function ThreadPriorMessage({
|
|||||||
isSpam={isSpam}
|
isSpam={isSpam}
|
||||||
isLast={false}
|
isLast={false}
|
||||||
starred={mailFlagIsStarred(message.flags ?? [])}
|
starred={mailFlagIsStarred(message.flags ?? [])}
|
||||||
|
attachments={attachments}
|
||||||
onCollapse={onToggle}
|
onCollapse={onToggle}
|
||||||
onPrintConversation={onPrintConversation}
|
onPrintConversation={onPrintConversation}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
@ -169,11 +179,13 @@ export function CollapsedMessage({
|
|||||||
message,
|
message,
|
||||||
senderName: senderNameProp,
|
senderName: senderNameProp,
|
||||||
senderEmail: senderEmailProp,
|
senderEmail: senderEmailProp,
|
||||||
|
attachmentCount = 0,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
message: ApiMessageFull
|
message: ApiMessageFull
|
||||||
senderName?: string
|
senderName?: string
|
||||||
senderEmail?: string
|
senderEmail?: string
|
||||||
|
attachmentCount?: number
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const senderName = senderNameProp ?? message.from[0]?.name ?? ""
|
const senderName = senderNameProp ?? message.from[0]?.name ?? ""
|
||||||
@ -206,6 +218,19 @@ export function CollapsedMessage({
|
|||||||
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
|
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
|
||||||
</ContactHoverCard>
|
</ContactHoverCard>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{attachmentCount > 0 ? (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 text-xs text-muted-foreground"
|
||||||
|
title={
|
||||||
|
attachmentCount === 1
|
||||||
|
? "Une pièce jointe"
|
||||||
|
: `${attachmentCount} pièces jointes`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Paperclip className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||||
|
{attachmentCount > 1 ? <span>{attachmentCount}</span> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<MailDateText
|
<MailDateText
|
||||||
iso={message.date}
|
iso={message.date}
|
||||||
variant="preview"
|
variant="preview"
|
||||||
@ -302,7 +327,7 @@ export function ExpandedMessage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<MessageAttachmentsSection attachments={attachments} />
|
<MessageAttachmentsSection messageId={messageId} attachments={attachments} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Info,
|
Info,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -20,24 +25,35 @@ import {
|
|||||||
shouldUseAttachmentPillsInPreview,
|
shouldUseAttachmentPillsInPreview,
|
||||||
} from "@/lib/attachment-display"
|
} from "@/lib/attachment-display"
|
||||||
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
|
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
|
||||||
|
import { MailDriveFolderPicker } from "@/components/mail/mail-drive-folder-picker"
|
||||||
|
import { useSaveMessageAttachmentsToDrive } from "@/lib/api/hooks/use-mail-drive-save"
|
||||||
|
import {
|
||||||
|
mailDriveFileHref,
|
||||||
|
mailDriveFolderHref,
|
||||||
|
mailDriveFolderLabel,
|
||||||
|
mailDriveFolderPathLabel,
|
||||||
|
mailDriveSaveErrorMessage,
|
||||||
|
mailDriveSaveSuccessMessage,
|
||||||
|
} from "@/lib/mail/mail-drive"
|
||||||
|
import {
|
||||||
|
mailAttachmentPreviewable,
|
||||||
|
openMailAttachmentsPreview,
|
||||||
|
} from "@/lib/mail/mail-attachment-preview"
|
||||||
|
import {
|
||||||
|
MailAttachmentPillThumb,
|
||||||
|
MailAttachmentThumbnail,
|
||||||
|
} from "@/components/gmail/email-view/mail-attachment-thumbnail"
|
||||||
|
|
||||||
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
|
function MessageAttachmentCard({
|
||||||
|
attachment,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
attachment: EmailAttachment
|
||||||
|
kind: EmailAttachmentKind
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
|
<MailAttachmentThumbnail attachment={attachment} />
|
||||||
{kind === "image" ? (
|
|
||||||
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
|
|
||||||
) : kind === "pdf" ? (
|
|
||||||
<div
|
|
||||||
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
|
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
|
||||||
{kind === "pdf" ? (
|
{kind === "pdf" ? (
|
||||||
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
||||||
@ -47,57 +63,276 @@ function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttach
|
|||||||
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
|
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
|
||||||
{name}
|
{attachment.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageAttachmentPill({
|
function DriveLocationBadge({ folderPath }: { folderPath: string }) {
|
||||||
name,
|
const label = mailDriveFolderPathLabel(folderPath)
|
||||||
kind,
|
|
||||||
sizeBytes,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
kind: EmailAttachmentKind
|
|
||||||
sizeBytes?: number
|
|
||||||
}) {
|
|
||||||
const tip = attachmentPreviewTooltip(name, sizeBytes)
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Link
|
||||||
<TooltipTrigger asChild>
|
href={mailDriveFolderHref(folderPath)}
|
||||||
<button
|
className="inline-flex max-w-full min-w-0 items-center gap-1.5 rounded-md py-1 pl-1 pr-2 text-sm text-primary hover:bg-accent"
|
||||||
type="button"
|
title={folderPath}
|
||||||
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
>
|
||||||
>
|
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
||||||
{kind === "pdf" ? (
|
<span className="min-w-0 truncate">{label}</span>
|
||||||
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
<ExternalLink className="size-3.5 shrink-0 opacity-70" aria-hidden />
|
||||||
) : kind === "image" ? (
|
</Link>
|
||||||
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
|
|
||||||
) : (
|
|
||||||
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 truncate font-medium">{name}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
|
||||||
{tip}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
|
export function MessageAttachmentsSection({
|
||||||
|
messageId,
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
messageId: string
|
||||||
|
attachments: EmailAttachment[]
|
||||||
|
}) {
|
||||||
const n = attachments.length
|
const n = attachments.length
|
||||||
|
const router = useRouter()
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const saveAll = useSaveMessageAttachmentsToDrive(messageId)
|
||||||
|
|
||||||
|
const savedCount = attachments.filter((a) => a.drivePath).length
|
||||||
|
const allSaved = n > 0 && savedCount === n
|
||||||
|
const noneSaved = savedCount === 0
|
||||||
|
const uniqueSaveFolders = useMemo(() => {
|
||||||
|
const folders = new Set(
|
||||||
|
attachments
|
||||||
|
.map((a) => a.drivePath)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => {
|
||||||
|
const idx = p!.lastIndexOf("/")
|
||||||
|
return idx > 0 ? p!.slice(0, idx) : p!
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return [...folders]
|
||||||
|
}, [attachments])
|
||||||
|
|
||||||
if (n === 0) return null
|
if (n === 0) return null
|
||||||
|
|
||||||
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
|
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
|
||||||
const asPills = shouldUseAttachmentPillsInPreview(attachments)
|
const asPills = shouldUseAttachmentPillsInPreview(attachments)
|
||||||
|
|
||||||
|
const openPreview = (index: number) => {
|
||||||
|
if (!attachments.some((a) => a.id)) {
|
||||||
|
toast.message("Pièce jointe non disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mailAttachmentPreviewable(attachments[index]!)) {
|
||||||
|
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openMailAttachmentsPreview(messageId, attachments, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSaveAll = async (folderPath: string) => {
|
||||||
|
try {
|
||||||
|
await saveAll.mutateAsync(folderPath)
|
||||||
|
setPickerOpen(false)
|
||||||
|
const folderLabel = mailDriveFolderPathLabel(folderPath)
|
||||||
|
toast.success(
|
||||||
|
n === 1
|
||||||
|
? mailDriveSaveSuccessMessage(folderPath)
|
||||||
|
: `${n} pièces jointes enregistrées dans ${folderLabel}`,
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: "Ouvrir le dossier",
|
||||||
|
onClick: () => router.push(mailDriveFolderHref(folderPath)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(mailDriveSaveErrorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
<>
|
||||||
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
<MailDriveFolderPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
title={n === 1 ? "Enregistrer dans UltiDrive" : `Enregistrer ${n} pièces jointes`}
|
||||||
|
description="Choisissez un dossier dans votre Drive."
|
||||||
|
confirmLabel="Enregistrer ici"
|
||||||
|
pending={saveAll.isPending}
|
||||||
|
onConfirm={onSaveAll}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
||||||
|
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
||||||
|
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{summary}
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span>Analysé par VirusTotal</span>
|
||||||
|
</span>
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
||||||
|
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
|
||||||
|
>
|
||||||
|
<Info className="size-4" strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
|
||||||
|
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
|
||||||
|
repérer les virus et logiciels malveillants.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{allSaved && uniqueSaveFolders.length === 1 ? (
|
||||||
|
<DriveLocationBadge folderPath={uniqueSaveFolders[0]!} />
|
||||||
|
) : allSaved && uniqueSaveFolders.length > 1 ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<HardDrive className="size-[18px] shrink-0 text-primary" strokeWidth={1.5} aria-hidden />
|
||||||
|
Enregistré dans UltiDrive ({savedCount}/{n})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent disabled:opacity-50"
|
||||||
|
aria-label="Ajouter à UltiDrive"
|
||||||
|
disabled={saveAll.isPending}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
||||||
|
{noneSaved
|
||||||
|
? "Ajouter à UltiDrive"
|
||||||
|
: `Ajouter le reste à UltiDrive (${n - savedCount})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
asPills
|
||||||
|
? "flex flex-wrap gap-2 pb-1"
|
||||||
|
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
||||||
|
}
|
||||||
|
role="list"
|
||||||
|
aria-label="Pièces jointes"
|
||||||
|
>
|
||||||
|
{attachments.map((att, index) => {
|
||||||
|
const kind = resolveAttachmentKind(att.name, att.kind)
|
||||||
|
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
|
||||||
|
const previewable = mailAttachmentPreviewable(att)
|
||||||
|
if (asPills) {
|
||||||
|
return (
|
||||||
|
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPreview(index)}
|
||||||
|
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||||
|
>
|
||||||
|
<MailAttachmentPillThumb attachment={att} />
|
||||||
|
<span className="min-w-0 truncate font-medium">{att.name}</span>
|
||||||
|
{att.drivePath ? (
|
||||||
|
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
||||||
|
{tip}
|
||||||
|
{previewable ? "\nCliquer pour prévisualiser" : ""}
|
||||||
|
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`${att.id ?? att.name}-${index}`} className="shrink-0" role="listitem">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPreview(index)}
|
||||||
|
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||||
|
>
|
||||||
|
<MessageAttachmentCard attachment={att} kind={kind} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
||||||
|
{tip}
|
||||||
|
{previewable ? "\nCliquer pour prévisualiser" : ""}
|
||||||
|
{att.drivePath ? `\n${mailDriveFolderLabel(att.drivePath)}` : ""}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationAttachmentEntry = {
|
||||||
|
messageId: string
|
||||||
|
senderName: string
|
||||||
|
attachments: EmailAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationAttachmentsSection({
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
entries: ConversationAttachmentEntry[]
|
||||||
|
}) {
|
||||||
|
const flat = useMemo(() => {
|
||||||
|
const items: {
|
||||||
|
messageId: string
|
||||||
|
senderName: string
|
||||||
|
attachments: EmailAttachment[]
|
||||||
|
index: number
|
||||||
|
attachment: EmailAttachment
|
||||||
|
}[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
entry.attachments.forEach((attachment, index) => {
|
||||||
|
items.push({
|
||||||
|
messageId: entry.messageId,
|
||||||
|
senderName: entry.senderName,
|
||||||
|
attachments: entry.attachments,
|
||||||
|
index,
|
||||||
|
attachment,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const n = flat.length
|
||||||
|
if (n === 0) return null
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
n === 1
|
||||||
|
? "Une pièce jointe dans cette conversation"
|
||||||
|
: `${n} pièces jointes dans cette conversation`
|
||||||
|
const asPills = shouldUseAttachmentPillsInPreview(flat.map((item) => item.attachment))
|
||||||
|
|
||||||
|
const openPreview = (messageId: string, attachments: EmailAttachment[], index: number) => {
|
||||||
|
if (!attachments.some((a) => a.id)) {
|
||||||
|
toast.message("Pièce jointe non disponible")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const att = attachments[index]
|
||||||
|
if (!att || !mailAttachmentPreviewable(att)) {
|
||||||
|
toast.message("Aperçu non disponible — téléchargez la pièce jointe")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openMailAttachmentsPreview(messageId, attachments, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
||||||
|
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span className="min-w-0 truncate">
|
<span className="min-w-0 truncate">
|
||||||
{summary}
|
{summary}
|
||||||
@ -120,14 +355,6 @@ export function MessageAttachmentsSection({ attachments }: { attachments: EmailA
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
|
|
||||||
aria-label="Ajouter à UltiDrive"
|
|
||||||
>
|
|
||||||
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
|
||||||
Ajouter à UltiDrive
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -137,30 +364,70 @@ export function MessageAttachmentsSection({ attachments }: { attachments: EmailA
|
|||||||
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
||||||
}
|
}
|
||||||
role="list"
|
role="list"
|
||||||
aria-label="Pièces jointes"
|
aria-label="Pièces jointes de la conversation"
|
||||||
>
|
>
|
||||||
{attachments.map((att, index) => {
|
{flat.map((item, flatIndex) => {
|
||||||
const kind = resolveAttachmentKind(att.name, att.kind)
|
const kind = resolveAttachmentKind(item.attachment.name, item.attachment.kind)
|
||||||
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
|
const previewable = mailAttachmentPreviewable(item.attachment)
|
||||||
|
const tip = [
|
||||||
|
item.senderName,
|
||||||
|
attachmentPreviewTooltip(item.attachment.name, item.attachment.sizeBytes),
|
||||||
|
previewable ? "Cliquer pour prévisualiser" : "",
|
||||||
|
item.attachment.drivePath ? mailDriveFolderLabel(item.attachment.drivePath) : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
if (asPills) {
|
if (asPills) {
|
||||||
return (
|
return (
|
||||||
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
|
<div
|
||||||
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
|
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
|
||||||
|
className="shrink-0"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
|
||||||
|
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||||
|
>
|
||||||
|
<MailAttachmentPillThumb attachment={item.attachment} />
|
||||||
|
<span className="min-w-0 truncate font-medium">{item.attachment.name}</span>
|
||||||
|
{item.attachment.drivePath ? (
|
||||||
|
<HardDrive className="size-3.5 shrink-0 text-primary" aria-label="Dans UltiDrive" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
|
||||||
|
>
|
||||||
|
{tip}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
|
<div
|
||||||
|
key={`${item.messageId}-${item.attachment.id ?? item.attachment.name}-${flatIndex}`}
|
||||||
|
className="shrink-0"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => openPreview(item.messageId, item.attachments, item.index)}
|
||||||
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||||
>
|
>
|
||||||
<MessageAttachmentCard name={att.name} kind={kind} />
|
<MessageAttachmentCard attachment={item.attachment} kind={kind} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}
|
||||||
|
>
|
||||||
{tip}
|
{tip}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -114,6 +114,7 @@ export function SandboxedContent({
|
|||||||
csp: cspContent,
|
csp: cspContent,
|
||||||
wrapperCss: themeCss,
|
wrapperCss: themeCss,
|
||||||
plainTextFallback,
|
plainTextFallback,
|
||||||
|
loadAppFont: !blockRemoteContent,
|
||||||
bodyTailCss: isDark
|
bodyTailCss: isDark
|
||||||
? blockRemoteContent
|
? blockRemoteContent
|
||||||
? emailPreviewDarkTailOverrideCss()
|
? emailPreviewDarkTailOverrideCss()
|
||||||
|
|||||||
@ -1,235 +1,3 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react"
|
export { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||||
import Link from "next/link"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Icon, addCollection } from "@iconify/react"
|
|
||||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
|
||||||
import { Pencil } from "lucide-react"
|
|
||||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
|
||||||
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
|
||||||
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const HEADER_ICON_BTN_CLASS = cn(
|
|
||||||
"rounded-full",
|
|
||||||
MAIL_ICON_BTN,
|
|
||||||
"hover:text-accent-foreground",
|
|
||||||
)
|
|
||||||
|
|
||||||
addCollection(mdiIcons)
|
|
||||||
|
|
||||||
type FavoriteApp = {
|
|
||||||
name: string
|
|
||||||
icon: string
|
|
||||||
href?: string
|
|
||||||
/** Logos sombres : blanc en dark via invert + hue-rotate. */
|
|
||||||
whiteLogoInDark?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleApps: FavoriteApp[] = [
|
|
||||||
{ name: "Compte", icon: "/compte-mark.svg" },
|
|
||||||
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
|
||||||
{ name: "Photos", icon: "/photos-mark.svg" },
|
|
||||||
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
|
|
||||||
{ name: "UltiDrive", icon: "/ultidrive-mark.svg", href: "/drive" },
|
|
||||||
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
|
|
||||||
{ name: "Administration", icon: "/admin-mark.svg" },
|
|
||||||
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
|
||||||
{ name: "Mistral", icon: "/mistral-mark.svg" },
|
|
||||||
{ name: "Qwant", icon: "/qwant-mark.svg", whiteLogoInDark: true },
|
|
||||||
{ name: "Ground News", icon: "/ground-news-mark.svg", whiteLogoInDark: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const FAVORITE_TILE_CLASS =
|
|
||||||
"flex flex-col items-center gap-2 rounded-lg p-3 transition-colors hover:bg-accent"
|
|
||||||
|
|
||||||
function FavoriteAppTile({ app }: { app: FavoriteApp }) {
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={app.icon}
|
|
||||||
alt={app.name}
|
|
||||||
className={cn(
|
|
||||||
"h-10 w-10 object-contain",
|
|
||||||
app.whiteLogoInDark && "dark:invert dark:hue-rotate-180",
|
|
||||||
)}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement
|
|
||||||
target.style.display = "none"
|
|
||||||
target.parentElement!.innerHTML = `<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white">${app.name[0]}</div>`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="w-full text-center text-xs text-muted-foreground">{app.name}</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (app.href) {
|
|
||||||
return (
|
|
||||||
<Link href={app.href} className={FAVORITE_TILE_CLASS}>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" className={FAVORITE_TILE_CLASS}>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeaderAccountActionsProps {
|
|
||||||
className?: string
|
|
||||||
/** When set, the settings button navigates here instead of opening quick settings. */
|
|
||||||
settingsHref?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderAccountActions({
|
|
||||||
className,
|
|
||||||
settingsHref,
|
|
||||||
}: HeaderAccountActionsProps) {
|
|
||||||
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
|
||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
|
||||||
const appsMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const accountMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
const identity = useChromeIdentity()
|
|
||||||
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const notice = sessionStorage.getItem("ulti_account_notice")
|
|
||||||
if (notice === "same") {
|
|
||||||
sessionStorage.removeItem("ulti_account_notice")
|
|
||||||
toast.message("Vous utilisez déjà ce compte Ulti.")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (
|
|
||||||
appsMenuRef.current &&
|
|
||||||
!appsMenuRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setAppsMenuOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex shrink-0 items-center gap-1", className)}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn("hidden sm:inline-flex", HEADER_ICON_BTN_CLASS)}
|
|
||||||
aria-label="Aide"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="mdi:help-circle-outline"
|
|
||||||
className="size-6 shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={HEADER_ICON_BTN_CLASS}
|
|
||||||
aria-label="Réglages"
|
|
||||||
{...(settingsHref
|
|
||||||
? { asChild: true }
|
|
||||||
: { onClick: () => openQuickSettings(true) })}
|
|
||||||
>
|
|
||||||
{settingsHref ? (
|
|
||||||
<Link href={settingsHref}>
|
|
||||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="relative hidden sm:block" ref={appsMenuRef}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={HEADER_ICON_BTN_CLASS}
|
|
||||||
aria-label="Applications"
|
|
||||||
onClick={() => {
|
|
||||||
setAppsMenuOpen(!appsMenuOpen)
|
|
||||||
setAccountMenuOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="mdi:view-grid-outline"
|
|
||||||
className="size-6 shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{appsMenuOpen && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute right-0 top-12 z-50 w-96 rounded-2xl",
|
|
||||||
MAIL_HEADER_DROPDOWN_CLASS,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-border p-4">
|
|
||||||
<span className="text-lg font-normal text-foreground">
|
|
||||||
Vos favoris
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn("h-8 w-8", HEADER_ICON_BTN_CLASS)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-1 p-3">
|
|
||||||
{googleApps.map((app) => (
|
|
||||||
<FavoriteAppTile key={app.name} app={app} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative ml-2" ref={accountMenuRef}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-lg"
|
|
||||||
className="size-11 overflow-hidden rounded-full p-0"
|
|
||||||
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
|
|
||||||
aria-expanded={accountMenuOpen}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
onClick={() => {
|
|
||||||
setAccountMenuOpen(!accountMenuOpen)
|
|
||||||
setAppsMenuOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{identity ? (
|
|
||||||
<AccountAvatar
|
|
||||||
account={{ name: identity.name, email: identity.email }}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<AccountSwitcherDropdown
|
|
||||||
open={accountMenuOpen}
|
|
||||||
onOpenChange={setAccountMenuOpen}
|
|
||||||
containerRef={accountMenuRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { Menu, Search } from "lucide-react"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||||
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
|
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
|
||||||
import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
|
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||||
|
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@ -22,6 +23,8 @@ export function Header({
|
|||||||
hideSearch = false,
|
hideSearch = false,
|
||||||
onOpenMobileSearch,
|
onOpenMobileSearch,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
|
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2">
|
<header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2">
|
||||||
{/* Rail width = page spacer so search left edge lines up with `<main>`. */}
|
{/* Rail width = page spacer so search left edge lines up with `<main>`. */}
|
||||||
@ -70,7 +73,7 @@ export function Header({
|
|||||||
<UltiMailLogo className="min-h-8 shrink-0 hidden sm:flex" />
|
<UltiMailLogo className="min-h-8 shrink-0 hidden sm:flex" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HeaderAccountActions />
|
<HeaderAccountActions onSettingsClick={() => openQuickSettings(true)} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
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 {
|
import {
|
||||||
parseSearchParams,
|
parseSearchParams,
|
||||||
} from "@/lib/mail-search/search-params"
|
} from "@/lib/mail-search/search-params"
|
||||||
|
import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
|
||||||
import {
|
import {
|
||||||
buildQuickSearchParams,
|
buildQuickSearchParams,
|
||||||
submitMailSearch,
|
submitMailSearch,
|
||||||
@ -79,6 +80,8 @@ export function MailSearchBar({
|
|||||||
toggleChipAttachment,
|
toggleChipAttachment,
|
||||||
toggleChipLast7Days,
|
toggleChipLast7Days,
|
||||||
toggleChipFromMe,
|
toggleChipFromMe,
|
||||||
|
resetChips,
|
||||||
|
syncChipsFromParams,
|
||||||
reset,
|
reset,
|
||||||
} = useMailSearchStore.getState()
|
} = useMailSearchStore.getState()
|
||||||
|
|
||||||
@ -87,11 +90,14 @@ export function MailSearchBar({
|
|||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = currentSearchParams?.q ?? ""
|
if (!isOnSearchPage) {
|
||||||
if (q && !inputValue) {
|
resetChips()
|
||||||
setInputValue(q)
|
return
|
||||||
}
|
}
|
||||||
}, [currentSearchParams?.q])
|
const displayQuery = searchParamsToDisplayQuery(currentSearchParams)
|
||||||
|
setInputValue(displayQuery)
|
||||||
|
syncChipsFromParams(currentSearchParams, account?.email)
|
||||||
|
}, [isOnSearchPage, currentSearchParams, account?.email, setInputValue, resetChips, syncChipsFromParams])
|
||||||
|
|
||||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||||
@ -131,6 +137,7 @@ export function MailSearchBar({
|
|||||||
if (!Object.keys(params).length) return
|
if (!Object.keys(params).length) return
|
||||||
submitMailSearch(router, params, {
|
submitMailSearch(router, params, {
|
||||||
onAfter: () => {
|
onAfter: () => {
|
||||||
|
setInputValue(q.trim())
|
||||||
setDropdownOpen(false)
|
setDropdownOpen(false)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||||
|
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
|
||||||
|
} from "@/lib/mail-chrome-classes"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
import type {
|
import type {
|
||||||
InboxSortMode,
|
InboxSortMode,
|
||||||
@ -99,7 +103,7 @@ function ThemeModePicker({
|
|||||||
compact?: boolean
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 sm:max-w-md")}>
|
<div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 max-w-md lg:max-w-none")}>
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{THEME_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@ -136,14 +140,23 @@ function SettingsSection({
|
|||||||
action,
|
action,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
variant = "panel",
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
variant?: "panel" | "page"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className={cn("border-b border-border px-4 py-4", className)}>
|
<section
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border px-4 py-4",
|
||||||
|
variant === "page" &&
|
||||||
|
cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SectionHeader title={title} action={action} />
|
<SectionHeader title={title} action={action} />
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@ -186,11 +199,11 @@ export function MailSettingsFields({
|
|||||||
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
||||||
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
||||||
|
|
||||||
const sectionClassName = variant === "page" ? "px-0 py-5" : undefined
|
const isPage = variant === "page"
|
||||||
|
|
||||||
return (
|
const fields = (
|
||||||
<>
|
<>
|
||||||
<SettingsSection title="Densité" className={sectionClassName}>
|
<SettingsSection title="Densité" variant={variant}>
|
||||||
{DENSITY_OPTIONS.map((opt) => (
|
{DENSITY_OPTIONS.map((opt) => (
|
||||||
<QuickSettingsOption
|
<QuickSettingsOption
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@ -205,7 +218,7 @@ export function MailSettingsFields({
|
|||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="Thème"
|
title="Thème"
|
||||||
className={sectionClassName}
|
variant={variant}
|
||||||
action={
|
action={
|
||||||
variant === "panel" && onOpenThemeDialog ? (
|
variant === "panel" && onOpenThemeDialog ? (
|
||||||
<button
|
<button
|
||||||
@ -230,7 +243,7 @@ export function MailSettingsFields({
|
|||||||
<h3 className="mb-3 text-sm font-medium text-foreground">
|
<h3 className="mb-3 text-sm font-medium text-foreground">
|
||||||
Arrière-plan
|
Arrière-plan
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-2 sm:max-w-lg sm:grid-cols-4">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:max-w-lg lg:max-w-none">
|
||||||
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
@ -266,10 +279,7 @@ export function MailSettingsFields({
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection title="Type de boîte de réception" variant={variant}>
|
||||||
title="Type de boîte de réception"
|
|
||||||
className={sectionClassName}
|
|
||||||
>
|
|
||||||
{INBOX_OPTIONS.map((opt) => (
|
{INBOX_OPTIONS.map((opt) => (
|
||||||
<QuickSettingsOption
|
<QuickSettingsOption
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@ -282,7 +292,7 @@ export function MailSettingsFields({
|
|||||||
))}
|
))}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="Volet de lecture" className={sectionClassName}>
|
<SettingsSection title="Volet de lecture" variant={variant}>
|
||||||
{READING_PANE_OPTIONS.map((opt) => (
|
{READING_PANE_OPTIONS.map((opt) => (
|
||||||
<QuickSettingsOption
|
<QuickSettingsOption
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@ -298,7 +308,12 @@ export function MailSettingsFields({
|
|||||||
))}
|
))}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<section className={cn("px-4 py-4", variant === "page" && "px-0 py-5")}>
|
<section
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border px-4 py-4",
|
||||||
|
isPage && cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS)
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SectionHeader title="Fils de discussion" />
|
<SectionHeader title="Fils de discussion" />
|
||||||
<QuickSettingsCheckbox
|
<QuickSettingsCheckbox
|
||||||
label="Mode Conversation"
|
label="Mode Conversation"
|
||||||
@ -309,4 +324,10 @@ export function MailSettingsFields({
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isPage) {
|
||||||
|
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ export function MailToaster() {
|
|||||||
}
|
}
|
||||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
richColors
|
richColors
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,6 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
|||||||
toggleChipLast7Days,
|
toggleChipLast7Days,
|
||||||
toggleChipFromMe,
|
toggleChipFromMe,
|
||||||
resetChips,
|
resetChips,
|
||||||
reset,
|
|
||||||
} = useMailSearchStore.getState()
|
} = useMailSearchStore.getState()
|
||||||
|
|
||||||
const [advancedMode, setAdvancedMode] = useState(false)
|
const [advancedMode, setAdvancedMode] = useState(false)
|
||||||
@ -79,10 +78,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
|||||||
setAdvancedMode(false)
|
setAdvancedMode(false)
|
||||||
setTimeout(() => inputRef.current?.focus(), 50)
|
setTimeout(() => inputRef.current?.focus(), 50)
|
||||||
} else {
|
} else {
|
||||||
reset()
|
resetChips()
|
||||||
setAdvancedMode(false)
|
setAdvancedMode(false)
|
||||||
}
|
}
|
||||||
}, [open, initialQuery, setInputValue, reset])
|
}, [open, initialQuery, setInputValue, resetChips])
|
||||||
|
|
||||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||||
if (!inputValue.trim() || !searchContactResults?.length) return []
|
if (!inputValue.trim() || !searchContactResults?.length) return []
|
||||||
|
|||||||
@ -194,17 +194,20 @@ export function ThemeThumbnailIcon() {
|
|||||||
function ThemeModePreviewFrame({
|
function ThemeModePreviewFrame({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-col overflow-hidden rounded-md border border-border",
|
"flex w-full flex-col overflow-hidden rounded-md border border-border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{ backgroundColor: "#ffffff" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@ -226,15 +229,18 @@ function MailChromePreview({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("h-2 shrink-0", headerBg)} />
|
<div className="h-2 shrink-0" style={{ backgroundColor: headerBg }} />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className={cn("w-[24%] shrink-0", sidebarBg)} />
|
<div className="w-[24%] shrink-0" style={{ backgroundColor: sidebarBg }} />
|
||||||
<div className={cn("flex min-w-0 flex-1 flex-col p-0.5", listBg)}>
|
<div
|
||||||
<div className={cn("h-px w-full", lineBg)} />
|
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||||
<div className={cn("mt-0.5 h-px w-3/4", lineBg)} />
|
style={{ backgroundColor: listBg }}
|
||||||
<div className={cn("mt-0.5 h-px w-1/2", lineBg)} />
|
>
|
||||||
|
<div className="h-px w-full" style={{ backgroundColor: lineBg }} />
|
||||||
|
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: lineBg }} />
|
||||||
|
<div className="mt-0.5 h-px w-1/2" style={{ backgroundColor: lineBg }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("w-[30%] shrink-0", contentBg)} />
|
<div className="w-[30%] shrink-0" style={{ backgroundColor: contentBg }} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -242,13 +248,16 @@ function MailChromePreview({
|
|||||||
|
|
||||||
export function ThemeLightPreview({ className }: { className?: string }) {
|
export function ThemeLightPreview({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
<ThemeModePreviewFrame
|
||||||
|
className={cn("h-12", className)}
|
||||||
|
data-mail-theme-preview="light"
|
||||||
|
>
|
||||||
<MailChromePreview
|
<MailChromePreview
|
||||||
headerBg="bg-white"
|
headerBg="#ffffff"
|
||||||
sidebarBg="bg-[#f1f3f4]"
|
sidebarBg="#f1f3f4"
|
||||||
listBg="bg-white"
|
listBg="#ffffff"
|
||||||
contentBg="bg-[#e8f0fe]"
|
contentBg="#e8f0fe"
|
||||||
lineBg="bg-[#dadce0]"
|
lineBg="#dadce0"
|
||||||
/>
|
/>
|
||||||
</ThemeModePreviewFrame>
|
</ThemeModePreviewFrame>
|
||||||
)
|
)
|
||||||
@ -256,13 +265,17 @@ export function ThemeLightPreview({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export function ThemeDarkPreview({ className }: { className?: string }) {
|
export function ThemeDarkPreview({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
<ThemeModePreviewFrame
|
||||||
|
className={cn("h-12", className)}
|
||||||
|
data-mail-theme-preview="dark"
|
||||||
|
style={{ backgroundColor: "#202124" }}
|
||||||
|
>
|
||||||
<MailChromePreview
|
<MailChromePreview
|
||||||
headerBg="bg-[#202124]"
|
headerBg="#202124"
|
||||||
sidebarBg="bg-[#3c4043]"
|
sidebarBg="#3c4043"
|
||||||
listBg="bg-[#202124]"
|
listBg="#202124"
|
||||||
contentBg="bg-[#394457]"
|
contentBg="#394457"
|
||||||
lineBg="bg-[#5f6368]"
|
lineBg="#5f6368"
|
||||||
/>
|
/>
|
||||||
</ThemeModePreviewFrame>
|
</ThemeModePreviewFrame>
|
||||||
)
|
)
|
||||||
@ -270,25 +283,35 @@ export function ThemeDarkPreview({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export function ThemeSystemPreview({ className }: { className?: string }) {
|
export function ThemeSystemPreview({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
<ThemeModePreviewFrame
|
||||||
|
className={cn("h-12", className)}
|
||||||
|
data-mail-theme-preview="system"
|
||||||
|
style={{ backgroundColor: "#ffffff" }}
|
||||||
|
>
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="flex w-1/2 min-w-0 flex-col">
|
<div className="flex w-1/2 min-w-0 flex-col">
|
||||||
<div className="h-2 shrink-0 bg-white" />
|
<div className="h-2 shrink-0" style={{ backgroundColor: "#ffffff" }} />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="w-[24%] shrink-0 bg-[#f1f3f4]" />
|
<div className="w-[24%] shrink-0" style={{ backgroundColor: "#f1f3f4" }} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col bg-white p-0.5">
|
<div
|
||||||
<div className="h-px w-full bg-[#dadce0]" />
|
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||||
<div className="mt-0.5 h-px w-3/4 bg-[#dadce0]" />
|
style={{ backgroundColor: "#ffffff" }}
|
||||||
|
>
|
||||||
|
<div className="h-px w-full" style={{ backgroundColor: "#dadce0" }} />
|
||||||
|
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: "#dadce0" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-1/2 min-w-0 flex-col">
|
<div className="flex w-1/2 min-w-0 flex-col">
|
||||||
<div className="h-2 shrink-0 bg-[#202124]" />
|
<div className="h-2 shrink-0" style={{ backgroundColor: "#202124" }} />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="w-[24%] shrink-0 bg-[#3c4043]" />
|
<div className="w-[24%] shrink-0" style={{ backgroundColor: "#3c4043" }} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col bg-[#202124] p-0.5">
|
<div
|
||||||
<div className="h-px w-full bg-[#5f6368]" />
|
className="flex min-w-0 flex-1 flex-col p-0.5"
|
||||||
<div className="mt-0.5 h-px w-3/4 bg-[#5f6368]" />
|
style={{ backgroundColor: "#202124" }}
|
||||||
|
>
|
||||||
|
<div className="h-px w-full" style={{ backgroundColor: "#5f6368" }} />
|
||||||
|
<div className="mt-0.5 h-px w-3/4" style={{ backgroundColor: "#5f6368" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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"
|
} from "@/lib/api/hooks/use-contact-discovery"
|
||||||
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
||||||
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
||||||
|
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||||
import {
|
import {
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_PRIMARY_BTN_CLASS,
|
CONTACTS_PRIMARY_BTN_CLASS,
|
||||||
@ -92,7 +93,7 @@ export function LLMProvidersPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
|
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
|
||||||
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
@ -100,130 +101,133 @@ export function LLMProvidersPanel() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{draft.providers.map((provider, index) => (
|
<AutomationTabMasonry columns={2}>
|
||||||
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
{draft.providers.map((provider, index) => (
|
||||||
<div className="flex items-center justify-between">
|
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||||
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => removeProvider(index)}
|
size="icon"
|
||||||
aria-label="Supprimer"
|
onClick={() => removeProvider(index)}
|
||||||
>
|
aria-label="Supprimer"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Nom</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
value={provider.name}
|
||||||
|
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||||
|
placeholder="OpenAI"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">URL de base</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
value={provider.base_url}
|
||||||
|
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Clé API</Label>
|
||||||
|
<Input
|
||||||
|
className="mt-1 h-9"
|
||||||
|
type="password"
|
||||||
|
value={provider.api_key ?? ""}
|
||||||
|
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label className="text-xs">Modèle par défaut</Label>
|
||||||
|
<LLMModelSuggestInput
|
||||||
|
className="mt-1"
|
||||||
|
baseUrl={provider.base_url}
|
||||||
|
apiKey={provider.api_key}
|
||||||
|
value={provider.default_model}
|
||||||
|
onChange={(default_model) => updateProvider(index, { default_model })}
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||||
|
<h4 className="text-sm font-medium">Découverte de contacts</h4>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Nom</Label>
|
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||||
<Input
|
<Select
|
||||||
className="mt-1 h-9"
|
value={draft.default_provider_id}
|
||||||
value={provider.name}
|
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
|
||||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
>
|
||||||
placeholder="OpenAI"
|
<SelectTrigger className="mt-1 h-9">
|
||||||
/>
|
<SelectValue placeholder="Choisir…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{draft.providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name || p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Modèle pour l'enrichissement</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-9">
|
||||||
|
<SelectValue placeholder="Même que défaut" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{draft.providers.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name || p.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<Label className="text-xs">URL de base</Label>
|
<Label className="text-xs">Modèle LLM</Label>
|
||||||
<Input
|
<Input
|
||||||
className="mt-1 h-9"
|
className="mt-1 h-9"
|
||||||
value={provider.base_url}
|
value={draft.contact_discovery_model ?? ""}
|
||||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
onChange={(e) =>
|
||||||
placeholder="https://api.openai.com/v1"
|
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
|
||||||
/>
|
}
|
||||||
</div>
|
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Label className="text-xs">Clé API</Label>
|
|
||||||
<Input
|
|
||||||
className="mt-1 h-9"
|
|
||||||
type="password"
|
|
||||||
value={provider.api_key ?? ""}
|
|
||||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
|
||||||
placeholder="sk-…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Label className="text-xs">Modèle par défaut</Label>
|
|
||||||
<LLMModelSuggestInput
|
|
||||||
className="mt-1"
|
|
||||||
baseUrl={provider.base_url}
|
|
||||||
apiKey={provider.api_key}
|
|
||||||
value={provider.default_model}
|
|
||||||
onChange={(default_model) => updateProvider(index, { default_model })}
|
|
||||||
placeholder="gpt-4o-mini"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</AutomationTabMasonry>
|
||||||
|
|
||||||
<Button variant="outline" onClick={addProvider}>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Button variant="outline" onClick={addProvider}>
|
||||||
Ajouter un fournisseur
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Ajouter un fournisseur
|
||||||
|
</Button>
|
||||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
<Button
|
||||||
<h4 className="text-sm font-medium">Découverte de contacts</h4>
|
onClick={handleSave}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
disabled={updateSettings.isPending}
|
||||||
<div>
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
||||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
>
|
||||||
<Select
|
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
||||||
value={draft.default_provider_id}
|
</Button>
|
||||||
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-9">
|
|
||||||
<SelectValue placeholder="Choisir…" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{draft.providers.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.name || p.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Modèle pour l'enrichissement</Label>
|
|
||||||
<Select
|
|
||||||
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-9">
|
|
||||||
<SelectValue placeholder="Même que défaut" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{draft.providers.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.name || p.id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Label className="text-xs">Modèle LLM</Label>
|
|
||||||
<Input
|
|
||||||
className="mt-1 h-9"
|
|
||||||
value={draft.contact_discovery_model ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateSettings.isPending}
|
|
||||||
className={CONTACTS_PRIMARY_BTN_CLASS}
|
|
||||||
>
|
|
||||||
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function SearchProvidersPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
|
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
|
||||||
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||||
|
|||||||
@ -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"
|
} from "@/lib/api/hooks/use-mail-automation-queries"
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||||
|
import { WebhookTemplateVariablesPanel } from "@/components/gmail/settings/automation/webhook-template-variables-panel"
|
||||||
|
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||||
|
|
||||||
export function WebhooksPanel() {
|
export function WebhooksPanel() {
|
||||||
const { ready, authenticated } = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
@ -28,57 +30,62 @@ export function WebhooksPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
<AutomationTabMasonry columns={2}>
|
||||||
<Label className="text-xs">Nouveau webhook</Label>
|
<WebhookTemplateVariablesPanel />
|
||||||
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||||
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
<Label className="text-xs">Nouveau webhook</Label>
|
||||||
<textarea
|
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
value={template}
|
<textarea
|
||||||
onChange={(e) => setTemplate(e.target.value)}
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
||||||
placeholder="body_template JSON"
|
value={template}
|
||||||
/>
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
<Button
|
placeholder="body_template JSON"
|
||||||
type="button"
|
/>
|
||||||
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
<Button
|
||||||
onClick={() =>
|
type="button"
|
||||||
createWebhook.mutate({
|
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
||||||
name: name.trim(),
|
onClick={() =>
|
||||||
url: url.trim(),
|
createWebhook.mutate({
|
||||||
method: "POST",
|
name: name.trim(),
|
||||||
body_template: template,
|
url: url.trim(),
|
||||||
})
|
method: "POST",
|
||||||
}
|
body_template: template,
|
||||||
>
|
})
|
||||||
Créer le webhook
|
}
|
||||||
</Button>
|
>
|
||||||
</div>
|
Créer le webhook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showInitialLoad ? null : webhooks.length === 0 ? (
|
{showInitialLoad ? (
|
||||||
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
|
<p className="text-sm text-muted-foreground">Chargement…</p>
|
||||||
) : (
|
) : webhooks.length === 0 ? (
|
||||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
|
||||||
{webhooks.map((hook) => (
|
) : (
|
||||||
<li
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
key={hook.id}
|
{webhooks.map((hook) => (
|
||||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
<li
|
||||||
>
|
key={hook.id}
|
||||||
<div className="min-w-0">
|
className="flex items-start justify-between gap-2 px-3 py-3"
|
||||||
<p className="text-sm font-medium">{hook.name}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => deleteWebhook.mutate(hook.id)}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<div className="min-w-0">
|
||||||
</Button>
|
<p className="text-sm font-medium">{hook.name}</p>
|
||||||
</li>
|
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<Button
|
||||||
)}
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteWebhook.mutate(hook.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</AutomationTabMasonry>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { ArrowLeft } from "lucide-react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
isMailSettingsNavActive,
|
isMailSettingsNavActive,
|
||||||
|
isMailSettingsWideLayoutPath,
|
||||||
MAIL_SETTINGS_NAV,
|
MAIL_SETTINGS_NAV,
|
||||||
} from "@/lib/mail-settings/settings-nav"
|
} from "@/lib/mail-settings/settings-nav"
|
||||||
|
import {
|
||||||
|
mailNavRowClass,
|
||||||
|
MAIL_SETTINGS_MAIN_CARD_CLASS,
|
||||||
|
MAIL_SETTINGS_MAIN_INSET_CLASS,
|
||||||
|
} from "@/lib/mail-chrome-classes"
|
||||||
|
import { MailSettingsHeader } from "@/components/gmail/settings/mail-settings-header"
|
||||||
|
|
||||||
export function MailSettingsLayout({ children }: { children: React.ReactNode }) {
|
export function MailSettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-background">
|
<div
|
||||||
<header className="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-6">
|
data-mail-settings-app
|
||||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
|
||||||
<Link
|
>
|
||||||
href="/mail/inbox"
|
<MailSettingsHeader />
|
||||||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
|
||||||
aria-label="Retour à la boîte de réception"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-5" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-normal text-foreground">
|
|
||||||
Paramètres Ultimail
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configuration du compte, de l'affichage et des automatisations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||||
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border bg-muted/20 p-3 md:block lg:w-72">
|
<aside
|
||||||
|
data-mail-settings-sidebar
|
||||||
|
className="hidden w-64 shrink-0 overflow-y-auto bg-app-canvas p-3 md:block lg:w-72"
|
||||||
|
>
|
||||||
<nav className="space-y-1" aria-label="Sections des paramètres">
|
<nav className="space-y-1" aria-label="Sections des paramètres">
|
||||||
{MAIL_SETTINGS_NAV.map((item) => {
|
{MAIL_SETTINGS_NAV.map((item) => {
|
||||||
const active = isMailSettingsNavActive(pathname, item)
|
const active = isMailSettingsNavActive(pathname, item)
|
||||||
@ -44,17 +38,30 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
"flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-mail-nav-selected"
|
||||||
: "text-foreground hover:bg-accent/50"
|
: "hover:bg-mail-nav-hover"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="mt-0.5 size-4 shrink-0 opacity-70" />
|
<Icon
|
||||||
<span className="min-w-0">
|
className={cn(
|
||||||
<span className="block text-sm font-medium">{item.label}</span>
|
"mt-0.5 size-4 shrink-0 opacity-70",
|
||||||
<span className="block text-xs text-muted-foreground">
|
active ? "text-mail-nav-selected" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block text-sm font-medium",
|
||||||
|
active ? "text-mail-nav-selected" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs font-normal text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -64,41 +71,50 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
|
||||||
<nav
|
<div data-mail-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
|
||||||
className="shrink-0 border-b border-border bg-muted/20 px-2 py-2 md:hidden"
|
<nav
|
||||||
aria-label="Sections des paramètres"
|
className="shrink-0 border-b border-border px-2 py-2 md:hidden"
|
||||||
>
|
aria-label="Sections des paramètres"
|
||||||
<div className="flex gap-1 overflow-x-auto">
|
>
|
||||||
{MAIL_SETTINGS_NAV.map((item) => {
|
<div className="flex gap-1 overflow-x-auto">
|
||||||
const active = isMailSettingsNavActive(pathname, item)
|
{MAIL_SETTINGS_NAV.map((item) => {
|
||||||
const Icon = item.icon
|
const active = isMailSettingsNavActive(pathname, item)
|
||||||
return (
|
const Icon = item.icon
|
||||||
<Link
|
return (
|
||||||
key={item.id}
|
<Link
|
||||||
href={item.href}
|
key={item.id}
|
||||||
aria-label={item.label}
|
href={item.href}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-label={item.label}
|
||||||
className={cn(
|
aria-current={active ? "page" : undefined}
|
||||||
"flex shrink-0 items-center rounded-lg transition-colors",
|
className={cn(
|
||||||
active
|
"flex shrink-0 items-center rounded-lg",
|
||||||
? "gap-2 bg-accent px-3 py-2 text-accent-foreground"
|
active
|
||||||
: "size-9 justify-center text-foreground hover:bg-accent/50"
|
? cn("gap-2 px-3 py-2", mailNavRowClass({ isSelected: true }))
|
||||||
)}
|
: cn("size-9 justify-center", mailNavRowClass({ isSelected: false }))
|
||||||
>
|
)}
|
||||||
<Icon className="size-4 shrink-0 opacity-70" />
|
>
|
||||||
{active ? (
|
<Icon className="size-4 shrink-0 opacity-70" />
|
||||||
<span className="text-sm font-medium">{item.label}</span>
|
{active ? (
|
||||||
) : null}
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
</Link>
|
) : null}
|
||||||
)
|
</Link>
|
||||||
})}
|
)
|
||||||
</div>
|
})}
|
||||||
</nav>
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||||
<div className="mx-auto max-w-3xl">{children}</div>
|
<div
|
||||||
</main>
|
className={cn(
|
||||||
|
"mx-auto w-full max-w-3xl",
|
||||||
|
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||||
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
|
|
||||||
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
|
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
|
||||||
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
||||||
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
|
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
|
||||||
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
|
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
|
||||||
|
import { ApiTokensPanel } from "@/components/gmail/settings/automation/api-tokens-panel"
|
||||||
|
|
||||||
export function AutomationSettingsSection() {
|
export function AutomationSettingsSection() {
|
||||||
return (
|
return (
|
||||||
@ -37,10 +37,7 @@ export function AutomationSettingsSection() {
|
|||||||
<SearchProvidersPanel />
|
<SearchProvidersPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="tokens" className="mt-4">
|
<TabsContent value="tokens" className="mt-4">
|
||||||
<SettingsComingSoon
|
<ApiTokensPanel />
|
||||||
title="Tokens API agents"
|
|
||||||
description="Créez des jetons fine-grained pour agents IA (lecture partielle, envoi, catégorisation)."
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -21,6 +21,11 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||||
|
import {
|
||||||
|
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||||
|
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
|
||||||
|
} from "@/lib/mail-chrome-classes"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
|
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
|
||||||
@ -54,12 +59,16 @@ export function SignaturesSettingsSection() {
|
|||||||
/>
|
/>
|
||||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className={cn("space-y-6 lg:space-y-0", MAIL_SETTINGS_PAGE_MASONRY_CLASS)}>
|
||||||
<SignatureLibrary
|
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
|
||||||
signatures={signatures}
|
<SignatureLibrary
|
||||||
showInitialLoad={showInitialLoad}
|
signatures={signatures}
|
||||||
/>
|
showInitialLoad={showInitialLoad}
|
||||||
<IdentitySignatureAssignments signatures={signatures} />
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
|
||||||
|
<IdentitySignatureAssignments signatures={signatures} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -344,15 +353,15 @@ function IdentitySignatureRow({
|
|||||||
: NONE_SIGNATURE
|
: NONE_SIGNATURE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-2 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
|
<li className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 rounded-lg border border-border p-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-[10rem] max-w-full flex-1">
|
||||||
<p className="text-sm font-medium truncate">{identity.name}</p>
|
<p className="text-sm font-medium">{identity.name}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">{identity.email}</p>
|
<p className="text-xs text-muted-foreground break-all">{identity.email}</p>
|
||||||
{identity.is_default ? (
|
{identity.is_default ? (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 sm:w-64">
|
<div className="min-w-[10rem] max-w-full flex-[1_1_10rem]">
|
||||||
<Label className="text-xs sr-only">Signature par défaut</Label>
|
<Label className="text-xs sr-only">Signature par défaut</Label>
|
||||||
<Select
|
<Select
|
||||||
value={current}
|
value={current}
|
||||||
|
|||||||
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