Add Google OAuth, German locale, and ORM-backed user access

This commit is contained in:
mixa
2026-03-04 18:51:33 +03:00
parent 2fab944351
commit 1bdeddb2ff
26 changed files with 2488 additions and 583 deletions

View File

@@ -5,7 +5,9 @@
"": {
"name": "frontend",
"dependencies": {
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
@@ -191,18 +193,24 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],

View File

@@ -12,6 +12,8 @@
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",

File diff suppressed because it is too large Load Diff

View File

@@ -22,4 +22,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
export { Badge }

View File

@@ -36,4 +36,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, va
})
Button.displayName = 'Button'
export { Button, buttonVariants }
export { Button }

View File

@@ -0,0 +1,45 @@
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { cn } from '../../lib/utils'
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
export { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger }

View File

@@ -0,0 +1,46 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '../../lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '../../lib/utils'
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -10,12 +10,16 @@
--foreground: 222 23% 16%;
--card: 0 0% 100%;
--card-foreground: 222 23% 16%;
--popover: var(--card);
--popover-foreground: var(--card-foreground);
--border: 215 24% 86%;
--input: 215 24% 86%;
--primary: 217 87% 52%;
--primary-foreground: 0 0% 100%;
--muted: 216 25% 93%;
--muted-foreground: 220 12% 41%;
--accent: var(--muted);
--accent-foreground: var(--foreground);
--destructive: 359 65% 55%;
--destructive-foreground: 0 0% 100%;
}
@@ -121,4 +125,78 @@
@apply bg-background text-foreground antialiased;
font-family: Manrope, 'Noto Sans', 'Segoe UI', sans-serif;
}
.markdown-preview {
line-height: 1.6;
}
.markdown-preview > :first-child {
margin-top: 0;
}
.markdown-preview > :last-child {
margin-bottom: 0;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 700;
line-height: 1.25;
}
.markdown-preview h1 {
font-size: 1.5rem;
}
.markdown-preview h2 {
font-size: 1.25rem;
}
.markdown-preview h3 {
font-size: 1.125rem;
}
.markdown-preview p,
.markdown-preview ul,
.markdown-preview ol,
.markdown-preview pre,
.markdown-preview blockquote {
margin: 0.5rem 0;
}
.markdown-preview ul,
.markdown-preview ol {
padding-left: 1.25rem;
}
.markdown-preview ul {
list-style: disc;
}
.markdown-preview ol {
list-style: decimal;
}
.markdown-preview code {
border-radius: 0.375rem;
background: hsl(var(--muted));
padding: 0.1rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.875em;
}
.markdown-preview pre code {
display: block;
overflow-x: auto;
padding: 0.65rem 0.8rem;
}
.markdown-preview blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 0.75rem;
color: hsl(var(--muted-foreground));
}
}

View File

@@ -0,0 +1,101 @@
import { Button } from '../components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
import { Input } from '../components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'
import { LogOut } from 'lucide-react'
import type { FormEvent } from 'react'
type AdminUser = { id: number; username: string; theme: string; colorMode: string }
type Props = {
t: (k: string) => string
admin: string
users: AdminUser[]
newUsername: string
setNewUsername: (v: string) => void
newPass: string
setNewPass: (v: string) => void
newTheme: string
setNewTheme: (v: string) => void
newMode: string
setNewMode: (v: string) => void
themeOptions: string[]
modeOptions: string[]
onLogout: () => void
onCreateUser: (e: FormEvent) => void
onDeleteUser: (id: number) => void
}
export default function AdminPanel(props: Props) {
const {
t,
admin,
users,
newUsername,
setNewUsername,
newPass,
setNewPass,
newTheme,
setNewTheme,
newMode,
setNewMode,
themeOptions,
modeOptions,
onLogout,
onCreateUser,
onDeleteUser,
} = props
return (
<Card className="border-none bg-card/95 shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('admin')}</CardTitle>
<CardDescription>{admin}</CardDescription>
</div>
<Button variant="outline" onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" /> {t('logout')}
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2">
<Card className="shadow-sm">
<CardHeader><CardTitle className="text-base">{t('createUser')}</CardTitle></CardHeader>
<CardContent>
<form className="space-y-3" onSubmit={onCreateUser}>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t('username')} required />
<Input type="password" minLength={10} value={newPass} onChange={(e) => setNewPass(e.target.value)} placeholder={t('password')} required />
<div className="grid grid-cols-2 gap-2">
<Select value={newTheme} onValueChange={setNewTheme}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{themeOptions.map((opt) => <SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>)}</SelectContent>
</Select>
<Select value={newMode} onValueChange={setNewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{modeOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button className="w-full" type="submit">{t('createUser')}</Button>
</form>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader><CardTitle className="text-base">{t('users')}</CardTitle></CardHeader>
<CardContent className="space-y-2">
{users.map((u) => (
<div key={u.id} className="flex items-center justify-between rounded-md border p-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{u.username}</p>
<p className="text-xs text-muted-foreground">#{u.id} · {u.theme}/{u.colorMode}</p>
</div>
<Button size="sm" variant="destructive" onClick={() => onDeleteUser(u.id)}>{t('delete')}</Button>
</div>
))}
</CardContent>
</Card>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,98 @@
import { Button } from '../components/ui/button'
import { CardContent } from '../components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog'
import { Input } from '../components/ui/input'
type DriveView = 'all' | 'folders' | 'documents' | 'media' | 'archives' | 'tagged' | 'recent'
type Props = {
username: string
t: (k: string) => string
onUploadFile: () => void
onUploadFolder: () => void
folderDialog: boolean
setFolderDialog: (v: boolean) => void
path: string
folderName: string
setFolderName: (v: string) => void
onCreateFolder: () => void
view: DriveView
setView: (v: DriveView) => void
filesCount: number
activeTag: string
setActiveTag: (v: string) => void
tagCounts: Record<string, number>
selectedCount: number
onDownloadSelected: () => void
}
export default function TransferSection(props: Props) {
const {
username,
t,
onUploadFile,
onUploadFolder,
folderDialog,
setFolderDialog,
path,
folderName,
setFolderName,
onCreateFolder,
view,
setView,
filesCount,
activeTag,
setActiveTag,
tagCounts,
selectedCount,
onDownloadSelected,
} = props
return (
<CardContent className="space-y-3 p-4">
<div>
<p className="text-sm font-semibold">{username}</p>
<p className="text-xs text-muted-foreground">{t('accountSubtitle')}</p>
</div>
<Button className="w-full justify-start" onClick={onUploadFile}>{t('upload')}</Button>
<Button className="w-full justify-start" variant="outline" onClick={onUploadFolder}>{t('uploadFolder')}</Button>
<Button className="w-full justify-start" variant="outline" disabled={selectedCount === 0} onClick={onDownloadSelected}>
{t('download')} {selectedCount > 0 ? `(${selectedCount})` : ''}
</Button>
<Dialog open={folderDialog} onOpenChange={setFolderDialog}>
<DialogTrigger asChild>
<Button className="w-full justify-start" variant="outline">{t('newFolder')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('newFolder')}</DialogTitle>
<DialogDescription>{path}</DialogDescription>
</DialogHeader>
<Input value={folderName} onChange={(e) => setFolderName(e.target.value)} placeholder={t('newFolder')} />
<Button onClick={onCreateFolder}>{t('newFolder')}</Button>
</DialogContent>
</Dialog>
<div className="grid gap-1 rounded-lg bg-muted/60 p-2 text-xs">
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'all' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('all')}>{t('allFiles')} ({filesCount})</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'folders' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('folders')}>{t('folders')}</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'documents' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('documents')}>{t('documents')}</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'media' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('media')}>{t('media')}</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'archives' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('archives')}>{t('archives')}</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'tagged' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('tagged')}>{t('tagged')}</button>
<button type="button" className={`rounded-md px-2 py-1 text-left ${view === 'recent' ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setView('recent')}>{t('recent')}</button>
</div>
<div className="space-y-2 rounded-lg bg-muted/60 p-2 text-xs">
<p className="font-medium text-muted-foreground">{t('tags')}</p>
<button type="button" className={`block w-full rounded-md px-2 py-1 text-left ${!activeTag ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setActiveTag('')}># all</button>
{Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([tag, count]) => (
<button type="button" key={tag} className={`block w-full rounded-md px-2 py-1 text-left ${activeTag === tag ? 'bg-background font-semibold' : 'hover:bg-background/70'}`} onClick={() => setActiveTag(tag)}>#{tag} ({count})</button>
))}
</div>
</CardContent>
)
}

11
frontend/src/types/directory-input.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import 'react'
declare module 'react' {
type InputDirectoryFlag<T> = T extends HTMLInputElement ? string : never
interface InputHTMLAttributes<T> {
webkitdirectory?: InputDirectoryFlag<T>
directory?: InputDirectoryFlag<T>
mozdirectory?: InputDirectoryFlag<T>
}
}

View File

@@ -0,0 +1,2 @@
declare module '@radix-ui/react-context-menu'
declare module '@radix-ui/react-dropdown-menu'

View File

@@ -12,17 +12,24 @@ export default {
},
},
extend: {
screens: {
lg: '600px',
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))',
'card-foreground': 'hsl(var(--card-foreground))',
popover: 'hsl(var(--popover))',
'popover-foreground': 'hsl(var(--popover-foreground))',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
destructive: 'hsl(var(--destructive))',
'destructive-foreground': 'hsl(var(--destructive-foreground))',
},