Add Google OAuth, German locale, and ORM-backed user access
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -22,4 +22,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge }
|
||||
|
||||
@@ -36,4 +36,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, va
|
||||
})
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
|
||||
45
frontend/src/components/ui/context-menu.tsx
Normal file
45
frontend/src/components/ui/context-menu.tsx
Normal 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 }
|
||||
46
frontend/src/components/ui/dropdown-menu.tsx
Normal file
46
frontend/src/components/ui/dropdown-menu.tsx
Normal 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 }
|
||||
21
frontend/src/components/ui/textarea.tsx
Normal file
21
frontend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
101
frontend/src/lazy/AdminPanel.tsx
Normal file
101
frontend/src/lazy/AdminPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
frontend/src/lazy/TransferSection.tsx
Normal file
98
frontend/src/lazy/TransferSection.tsx
Normal 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
11
frontend/src/types/directory-input.d.ts
vendored
Normal 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>
|
||||
}
|
||||
}
|
||||
2
frontend/src/types/radix-fallback.d.ts
vendored
Normal file
2
frontend/src/types/radix-fallback.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module '@radix-ui/react-context-menu'
|
||||
declare module '@radix-ui/react-dropdown-menu'
|
||||
@@ -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))',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user