@xk2800/nextjs-template 0.7.0-beta.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +56 -322
  2. package/components/dashboard/admin/adminUsersTable.tsx +36 -2
  3. package/components/dashboard/admin/feedbackTestForm.tsx +55 -0
  4. package/components/dashboard/admin/systemSettingsForm.tsx +28 -0
  5. package/components/dashboard/appSidebar.tsx +4 -4
  6. package/components/dashboard/errorFeedback.tsx +65 -0
  7. package/components/dashboard/feedbackDialog.tsx +160 -0
  8. package/components/dashboard/installAppCard.tsx +187 -0
  9. package/components/site/cookie-banner.tsx +57 -0
  10. package/components/ui/button.tsx +1 -1
  11. package/components/ui/file-upload.tsx +178 -0
  12. package/components/ui/input.tsx +1 -1
  13. package/components/ui/separator.tsx +1 -1
  14. package/components/ui/sheet.tsx +1 -1
  15. package/components/ui/sidebar.tsx +8 -8
  16. package/components/ui/skeleton.tsx +1 -1
  17. package/components/ui/tooltip.tsx +1 -1
  18. package/dist/activity/logger.js +21 -1
  19. package/dist/activity/logger.js.map +1 -1
  20. package/dist/activity/logger.mjs +4 -4
  21. package/dist/activity/queries.d.mts +1 -1
  22. package/dist/activity/queries.d.ts +1 -1
  23. package/dist/activity/queries.js +21 -1
  24. package/dist/activity/queries.js.map +1 -1
  25. package/dist/activity/queries.mjs +3 -3
  26. package/dist/admin/queries.js +21 -1
  27. package/dist/admin/queries.js.map +1 -1
  28. package/dist/admin/queries.mjs +3 -3
  29. package/dist/api/feedback.d.mts +23 -0
  30. package/dist/api/feedback.d.ts +23 -0
  31. package/dist/api/feedback.js +1224 -0
  32. package/dist/api/feedback.js.map +1 -0
  33. package/dist/api/feedback.mjs +136 -0
  34. package/dist/api/feedback.mjs.map +1 -0
  35. package/dist/auth/helpers.d.mts +3 -3
  36. package/dist/auth/helpers.d.ts +3 -3
  37. package/dist/auth/helpers.js +24 -3
  38. package/dist/auth/helpers.js.map +1 -1
  39. package/dist/auth/helpers.mjs +7 -7
  40. package/dist/auth/helpers.mjs.map +1 -1
  41. package/dist/auth/index.js +23 -2
  42. package/dist/auth/index.js.map +1 -1
  43. package/dist/auth/index.mjs +6 -6
  44. package/dist/auth-client/index.d.mts +16 -16
  45. package/dist/auth-client/index.d.ts +16 -16
  46. package/dist/{chunk-IF3JZRSZ.mjs → chunk-3MGEAGHU.mjs} +6 -5
  47. package/dist/chunk-3MGEAGHU.mjs.map +1 -0
  48. package/dist/chunk-KAEA35OC.mjs +76 -0
  49. package/dist/chunk-KAEA35OC.mjs.map +1 -0
  50. package/dist/{chunk-GTPTVNY2.mjs → chunk-TMJYAOC2.mjs} +10 -2
  51. package/dist/chunk-TMJYAOC2.mjs.map +1 -0
  52. package/dist/{chunk-OHAUKE2Q.mjs → chunk-VQ5B4MA7.mjs} +4 -4
  53. package/dist/{chunk-OHAUKE2Q.mjs.map → chunk-VQ5B4MA7.mjs.map} +1 -1
  54. package/dist/{chunk-5PYDAD4P.mjs → chunk-WZDAMCUX.mjs} +4 -4
  55. package/dist/{chunk-5PYDAD4P.mjs.map → chunk-WZDAMCUX.mjs.map} +1 -1
  56. package/dist/{chunk-7Q5T4NZ7.mjs → chunk-XWVAJXAG.mjs} +9 -9
  57. package/dist/chunk-XWVAJXAG.mjs.map +1 -0
  58. package/dist/{chunk-4B7MJOLH.mjs → chunk-ZQCALTKB.mjs} +17 -5
  59. package/dist/chunk-ZQCALTKB.mjs.map +1 -0
  60. package/dist/config/env.d.mts +3 -0
  61. package/dist/config/env.d.ts +3 -0
  62. package/dist/config/env.js +8 -0
  63. package/dist/config/env.js.map +1 -1
  64. package/dist/config/env.mjs +1 -1
  65. package/dist/db/index.d.mts +1 -1
  66. package/dist/db/index.d.ts +1 -1
  67. package/dist/db/index.js +21 -1
  68. package/dist/db/index.js.map +1 -1
  69. package/dist/db/index.mjs +3 -3
  70. package/dist/db/schema.d.mts +1 -1
  71. package/dist/db/schema.d.ts +1 -1
  72. package/dist/db/schema.js +13 -1
  73. package/dist/db/schema.js.map +1 -1
  74. package/dist/db/schema.mjs +1 -1
  75. package/dist/errors.d.mts +29 -0
  76. package/dist/errors.d.ts +29 -0
  77. package/dist/errors.js +365 -0
  78. package/dist/errors.js.map +1 -0
  79. package/dist/errors.mjs +7 -0
  80. package/dist/errors.mjs.map +1 -0
  81. package/dist/{schema-BrBmQFkn.d.mts → schema-BNSBWNjK.d.mts} +98 -5
  82. package/dist/{schema-BrBmQFkn.d.ts → schema-BNSBWNjK.d.ts} +98 -5
  83. package/dist/sessions/queries.js +21 -1
  84. package/dist/sessions/queries.js.map +1 -1
  85. package/dist/sessions/queries.mjs +3 -3
  86. package/dist/settings/queries.d.mts +2 -2
  87. package/dist/settings/queries.d.ts +2 -2
  88. package/dist/settings/queries.js +22 -1
  89. package/dist/settings/queries.js.map +1 -1
  90. package/dist/settings/queries.mjs +4 -4
  91. package/dist/users/queries.d.mts +3 -3
  92. package/dist/users/queries.d.ts +3 -3
  93. package/dist/users/queries.js +21 -1
  94. package/dist/users/queries.js.map +1 -1
  95. package/dist/users/queries.mjs +3 -3
  96. package/hooks/use-mobile.ts +19 -0
  97. package/lib/device-fingerprint.ts +2 -3
  98. package/package.json +35 -32
  99. package/dist/chunk-4B7MJOLH.mjs.map +0 -1
  100. package/dist/chunk-7Q5T4NZ7.mjs.map +0 -1
  101. package/dist/chunk-GTPTVNY2.mjs.map +0 -1
  102. package/dist/chunk-IF3JZRSZ.mjs.map +0 -1
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, useSyncExternalStore } from "react"
4
+ import { toast, type ExternalToast } from "sonner"
5
+ import FeedbackDialog from "./feedbackDialog"
6
+
7
+ // One hidden feedback dialog for reporting errors. Mount <ErrorFeedbackHost />
8
+ // once in a layout, only when feedback is configured (FEEDBACK_DISCORD_WEBHOOK_URL
9
+ // or FEEDBACK_EMAIL). Error toasts (toastError) and error pages (reportProblem)
10
+ // then open it as a bug report with the error filled in and attached. Where
11
+ // it isn't mounted, toastError is a plain toast.error.
12
+ let openReport: ((errorText: string) => void) | null = null
13
+ const listeners = new Set<() => void>()
14
+ function setOpenReport(fn: typeof openReport) {
15
+ openReport = fn
16
+ listeners.forEach((listener) => listener())
17
+ }
18
+
19
+ export function ErrorFeedbackHost() {
20
+ const [errorText, setErrorText] = useState<string | null>(null)
21
+
22
+ useEffect(() => {
23
+ setOpenReport(setErrorText)
24
+ return () => setOpenReport(null)
25
+ }, [])
26
+
27
+ return (
28
+ <FeedbackDialog
29
+ showTrigger={false}
30
+ open={errorText !== null}
31
+ onOpenChange={(open) => !open && setErrorText(null)}
32
+ defaultType="bug"
33
+ errorDetails={errorText ?? undefined}
34
+ defaultMessage={
35
+ errorText ? `Error: ${errorText}\n\nWhat were you doing when this happened?\n` : ""
36
+ }
37
+ />
38
+ )
39
+ }
40
+
41
+ /** Whether a Report button can open the feedback form (host mounted and configured). */
42
+ export function useCanReportProblem() {
43
+ return useSyncExternalStore(
44
+ (listener) => {
45
+ listeners.add(listener)
46
+ return () => listeners.delete(listener)
47
+ },
48
+ () => openReport !== null,
49
+ () => false
50
+ )
51
+ }
52
+
53
+ export function reportProblem(errorText: string) {
54
+ openReport?.(errorText)
55
+ }
56
+
57
+ /** toast.error with a "Report" button that opens the feedback form pre-filled. */
58
+ export function toastError(message: string, options?: ExternalToast) {
59
+ return toast.error(message, {
60
+ ...(openReport && {
61
+ action: { label: "Report", onClick: () => reportProblem(message) },
62
+ }),
63
+ ...options,
64
+ })
65
+ }
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import { useState } from "react"
4
+ import { usePathname } from "next/navigation"
5
+ import { toast } from "sonner"
6
+ import { MessageSquare } from "lucide-react"
7
+ import { Button } from "../ui/button"
8
+ import { Label } from "../ui/label"
9
+ import { Textarea } from "../ui/textarea"
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogTrigger,
18
+ } from "../ui/dialog"
19
+
20
+ // Keep in sync with the cap in app/api/feedback/route.ts.
21
+ const FEEDBACK_MAX_LENGTH = 2000
22
+
23
+ type FeedbackType = "bug" | "feedback"
24
+
25
+ export type FeedbackDialogProps = {
26
+ /** Control the dialog from outside (e.g. a "Report" button on an error). */
27
+ open?: boolean
28
+ onOpenChange?: (open: boolean) => void
29
+ /** Pre-filled each time the dialog opens, e.g. the error the user just hit. */
30
+ defaultType?: FeedbackType
31
+ defaultMessage?: string
32
+ /**
33
+ * The error the user hit, sent alongside their message (not editable). The
34
+ * server only forwards it when "Attach error details" is on in System Settings.
35
+ */
36
+ errorDetails?: string
37
+ /** Render the "Feedback" header button. Default `true`. */
38
+ showTrigger?: boolean
39
+ }
40
+
41
+ /**
42
+ * Header button + dialog that POSTs to /api/feedback. Render it only when a
43
+ * feedback channel is configured (FEEDBACK_DISCORD_WEBHOOK_URL or
44
+ * FEEDBACK_EMAIL); otherwise the route answers 503.
45
+ */
46
+ export default function FeedbackDialog({
47
+ open: openProp,
48
+ onOpenChange,
49
+ defaultType = "feedback",
50
+ defaultMessage = "",
51
+ errorDetails,
52
+ showTrigger = true,
53
+ }: FeedbackDialogProps = {}) {
54
+ const pathname = usePathname()
55
+ const [openState, setOpenState] = useState(false)
56
+ const open = openProp ?? openState
57
+ const setOpen = (next: boolean) => {
58
+ setOpenState(next)
59
+ onOpenChange?.(next)
60
+ }
61
+ const [type, setType] = useState<FeedbackType>(defaultType)
62
+ const [message, setMessage] = useState(defaultMessage)
63
+ const [isSending, setIsSending] = useState(false)
64
+
65
+ // With a pre-filled message, re-apply it every time the dialog opens so a
66
+ // second error report doesn't start from the first one's text. Without one,
67
+ // keep whatever the user had typed (a closed dialog keeps its draft).
68
+ const [wasOpen, setWasOpen] = useState(open)
69
+ if (open !== wasOpen) {
70
+ setWasOpen(open)
71
+ if (open && defaultMessage) {
72
+ setType(defaultType)
73
+ setMessage(defaultMessage)
74
+ }
75
+ }
76
+
77
+ const handleSubmit = async (e: React.FormEvent) => {
78
+ e.preventDefault()
79
+ setIsSending(true)
80
+ try {
81
+ const response = await fetch("/api/feedback", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ type, message, page: pathname, error: errorDetails }),
85
+ })
86
+ const data = await response.json().catch(() => ({}))
87
+ if (!response.ok) throw new Error(data.error || "Failed to send feedback")
88
+
89
+ toast.success("Thanks! Your feedback was sent.")
90
+ setMessage("")
91
+ setOpen(false)
92
+ } catch (error) {
93
+ toast.error(error instanceof Error ? error.message : "Failed to send feedback")
94
+ } finally {
95
+ setIsSending(false)
96
+ }
97
+ }
98
+
99
+ return (
100
+ <Dialog open={open} onOpenChange={setOpen}>
101
+ {showTrigger && (
102
+ <DialogTrigger asChild>
103
+ <Button variant="ghost" size="sm">
104
+ <MessageSquare />
105
+ Feedback
106
+ </Button>
107
+ </DialogTrigger>
108
+ )}
109
+ <DialogContent>
110
+ <form onSubmit={handleSubmit} className="space-y-4">
111
+ <DialogHeader>
112
+ <DialogTitle>Send feedback</DialogTitle>
113
+ <DialogDescription>
114
+ Report a bug or tell us what could be better.
115
+ </DialogDescription>
116
+ </DialogHeader>
117
+
118
+ <div className="flex gap-2" role="radiogroup" aria-label="Feedback type">
119
+ {(["feedback", "bug"] as const).map((t) => (
120
+ <Button
121
+ key={t}
122
+ type="button"
123
+ role="radio"
124
+ aria-checked={type === t}
125
+ variant={type === t ? "default" : "outline"}
126
+ size="sm"
127
+ onClick={() => setType(t)}
128
+ >
129
+ {t === "bug" ? "Bug report" : "General feedback"}
130
+ </Button>
131
+ ))}
132
+ </div>
133
+
134
+ <div className="space-y-2">
135
+ <Label htmlFor="feedback-message">
136
+ {type === "bug" ? "What happened, and what did you expect?" : "Your feedback"}
137
+ </Label>
138
+ <Textarea
139
+ id="feedback-message"
140
+ value={message}
141
+ onChange={(e) => setMessage(e.target.value)}
142
+ maxLength={FEEDBACK_MAX_LENGTH}
143
+ rows={6}
144
+ required
145
+ />
146
+ <p className="text-xs text-muted-foreground text-right">
147
+ {message.length}/{FEEDBACK_MAX_LENGTH}
148
+ </p>
149
+ </div>
150
+
151
+ <DialogFooter>
152
+ <Button type="submit" disabled={isSending || !message.trim()}>
153
+ {isSending ? "Sending..." : "Send"}
154
+ </Button>
155
+ </DialogFooter>
156
+ </form>
157
+ </DialogContent>
158
+ </Dialog>
159
+ )
160
+ }
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { CircleCheck, Download } from 'lucide-react'
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card'
6
+ import { Button } from '../ui/button'
7
+
8
+ // Chrome/Edge's install event; not in the DOM typings yet.
9
+ type InstallPromptEvent = Event & { prompt: () => Promise<{ outcome: 'accepted' | 'dismissed' }> }
10
+
11
+ declare global {
12
+ interface Window {
13
+ // Stashed by app/layout.tsx in case the event fires before this card mounts.
14
+ __pwaInstallPrompt?: InstallPromptEvent
15
+ }
16
+ }
17
+
18
+ export type InstallTarget = {
19
+ os: 'ios' | 'android' | 'desktop'
20
+ browser: 'safari' | 'chrome' | 'edge' | 'firefox' | 'samsung' | 'other'
21
+ }
22
+
23
+ // iPadOS reports a Mac user agent, so touch support tells them apart.
24
+ export function detectInstallTarget(ua: string, maxTouchPoints = 0): InstallTarget {
25
+ const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && maxTouchPoints > 1)
26
+ const os = ios ? 'ios' : /Android/.test(ua) ? 'android' : 'desktop'
27
+ const browser = /SamsungBrowser/.test(ua)
28
+ ? 'samsung'
29
+ : /Edg(e|A|iOS)?\//.test(ua)
30
+ ? 'edge'
31
+ : /Firefox|FxiOS/.test(ua)
32
+ ? 'firefox'
33
+ : /OPR\/|Opera/.test(ua)
34
+ ? 'other'
35
+ : /Chrome|CriOS/.test(ua)
36
+ ? 'chrome'
37
+ : /Safari/.test(ua)
38
+ ? 'safari'
39
+ : 'other'
40
+ return { os, browser }
41
+ }
42
+
43
+ const BROWSER_NAMES: Record<InstallTarget['browser'], string> = {
44
+ safari: 'Safari',
45
+ chrome: 'Chrome',
46
+ edge: 'Edge',
47
+ firefox: 'Firefox',
48
+ samsung: 'Samsung Internet',
49
+ other: 'your browser',
50
+ }
51
+ const OS_NAMES: Record<InstallTarget['os'], string> = {
52
+ ios: 'iPhone / iPad',
53
+ android: 'Android',
54
+ desktop: 'this computer',
55
+ }
56
+
57
+ // Manual steps for each OS/browser, or null when the browser can't install apps.
58
+ function steps({ os, browser }: InstallTarget): ReactNode[] | null {
59
+ if (os === 'ios') {
60
+ return [
61
+ browser === 'safari' ? (
62
+ <>Tap <strong>Share</strong> in the toolbar. It may be inside the <strong>⋯</strong> menu.</>
63
+ ) : (
64
+ <>Tap <strong>Share</strong> in the address bar or menu.</>
65
+ ),
66
+ <>Choose <strong>Add to Home Screen</strong>. Keep &ldquo;Open as Web App&rdquo; on if shown.</>,
67
+ <>Tap <strong>Add</strong>, then open the new icon on your home screen.</>,
68
+ ]
69
+ }
70
+ if (os === 'android') {
71
+ if (browser === 'samsung') {
72
+ return [
73
+ <>Tap the <strong>☰</strong> menu at the bottom right.</>,
74
+ <>Choose <strong>Add page to</strong> → <strong>Home screen</strong>.</>,
75
+ ]
76
+ }
77
+ return [
78
+ <>Tap the <strong>⋮</strong> menu at the top right.</>,
79
+ <>Choose <strong>Install app</strong> or <strong>Add to Home screen</strong>.</>,
80
+ ]
81
+ }
82
+ if (browser === 'chrome') {
83
+ return [
84
+ <>
85
+ Click the install icon at the right of the address bar, or open the <strong>⋮</strong> menu
86
+ and choose <strong>Cast, save and share</strong> → <strong>Install page as app</strong>.
87
+ </>,
88
+ ]
89
+ }
90
+ if (browser === 'edge') {
91
+ return [
92
+ <>
93
+ Click the install icon at the right of the address bar, or open the <strong>⋯</strong> menu
94
+ and choose <strong>Apps</strong> → <strong>Install this site as an app</strong>.
95
+ </>,
96
+ ]
97
+ }
98
+ if (browser === 'safari') {
99
+ return [<>Choose <strong>File</strong> → <strong>Add to Dock</strong> (macOS Sonoma or later).</>]
100
+ }
101
+ return null
102
+ }
103
+
104
+ // Install instructions for the visitor's own OS and browser, plus a one-click
105
+ // Install button where the browser offers one (Chrome, Edge). Shows a
106
+ // confirmation instead once the app runs installed.
107
+ export default function InstallAppCard() {
108
+ const [target, setTarget] = useState<InstallTarget | undefined>()
109
+ const [installed, setInstalled] = useState(false)
110
+ const [prompt, setPrompt] = useState<InstallPromptEvent | undefined>()
111
+
112
+ useEffect(() => {
113
+ setTarget(detectInstallTarget(navigator.userAgent, navigator.maxTouchPoints))
114
+ setInstalled(
115
+ matchMedia('(display-mode: standalone)').matches ||
116
+ (navigator as Navigator & { standalone?: boolean }).standalone === true
117
+ )
118
+ setPrompt(window.__pwaInstallPrompt)
119
+
120
+ const onPrompt = (e: Event) => setPrompt(e as InstallPromptEvent)
121
+ const onInstalled = () => setInstalled(true)
122
+ addEventListener('beforeinstallprompt', onPrompt)
123
+ addEventListener('appinstalled', onInstalled)
124
+ return () => {
125
+ removeEventListener('beforeinstallprompt', onPrompt)
126
+ removeEventListener('appinstalled', onInstalled)
127
+ }
128
+ }, [])
129
+
130
+ async function install() {
131
+ if (!prompt) return
132
+ const { outcome } = await prompt.prompt()
133
+ // The event can only be used once.
134
+ window.__pwaInstallPrompt = undefined
135
+ setPrompt(undefined)
136
+ if (outcome === 'accepted') setInstalled(true)
137
+ }
138
+
139
+ // Rendered after mount only: the server can't see the visitor's device.
140
+ if (!target) return null
141
+ const list = steps(target)
142
+
143
+ return (
144
+ <Card>
145
+ <CardHeader>
146
+ <CardTitle>Install the app</CardTitle>
147
+ <CardDescription>
148
+ Open it from your home screen or dock, in its own window.
149
+ </CardDescription>
150
+ </CardHeader>
151
+ <CardContent className="space-y-4">
152
+ {installed ? (
153
+ <p className="flex items-center gap-2 text-sm">
154
+ <CircleCheck className="size-4 text-green-600" aria-hidden />
155
+ You&apos;re using the installed app on this device.
156
+ </p>
157
+ ) : (
158
+ <>
159
+ <p className="text-sm text-muted-foreground">
160
+ {BROWSER_NAMES[target.browser]} on {OS_NAMES[target.os]}
161
+ </p>
162
+ {prompt && (
163
+ <Button onClick={install}>
164
+ <Download aria-hidden /> Install app
165
+ </Button>
166
+ )}
167
+ {list ? (
168
+ <ol className="list-decimal space-y-2 pl-5 text-sm marker:text-muted-foreground">
169
+ {list.map((step, i) => (
170
+ <li key={i}>{step}</li>
171
+ ))}
172
+ </ol>
173
+ ) : (
174
+ !prompt && (
175
+ <p className="text-sm">
176
+ This browser can&apos;t install apps. Open this page in{' '}
177
+ {target.os === 'desktop' ? 'Chrome, Edge, or Safari on a Mac' : 'Chrome'} to install
178
+ it.
179
+ </p>
180
+ )
181
+ )}
182
+ </>
183
+ )}
184
+ </CardContent>
185
+ </Card>
186
+ )
187
+ }
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { useEffect, useState } from "react"
5
+
6
+ // Relative imports: this file ships raw in the npm package, where `@/` would
7
+ // resolve against the consuming project instead.
8
+ import { Button } from "../ui/button"
9
+ import { COOKIE_BANNER_ENABLED, setCookieConsent, useCookieConsent } from "../../lib/cookie-consent"
10
+
11
+ // Mount once in the root layout. Shows until the visitor picks an option;
12
+ // "Necessary only" is as prominent as "Accept all" on purpose — GDPR requires
13
+ // rejecting to be as easy as accepting.
14
+ export function CookieBanner() {
15
+ const consent = useCookieConsent()
16
+ // useCookieConsent() is null on the server *and* before a choice, so wait
17
+ // for mount to tell them apart — otherwise returning visitors see a flash.
18
+ const [mounted, setMounted] = useState(false)
19
+ useEffect(() => setMounted(true), [])
20
+
21
+ if (!COOKIE_BANNER_ENABLED || !mounted || consent) return null
22
+
23
+ return (
24
+ <div
25
+ role="dialog"
26
+ aria-live="polite"
27
+ aria-label="Cookie consent"
28
+ className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-lg rounded-lg border border-border bg-background p-4 shadow-lg"
29
+ >
30
+ <p className="text-sm text-muted-foreground">
31
+ We use necessary cookies to keep you signed in. With your permission we also use a device
32
+ fingerprint to detect suspicious sign-ins, and Google One Tap sign-in.{" "}
33
+ <Link href="/privacy" className="underline underline-offset-4 hover:text-foreground">
34
+ Privacy policy
35
+ </Link>
36
+ </p>
37
+ <div className="mt-3 flex justify-end gap-2">
38
+ <Button variant="outline" size="sm" onClick={() => setCookieConsent("necessary")}>
39
+ Necessary only
40
+ </Button>
41
+ <Button size="sm" onClick={() => setCookieConsent("all")}>
42
+ Accept all
43
+ </Button>
44
+ </div>
45
+ </div>
46
+ )
47
+ }
48
+
49
+ // Withdrawing consent must be as easy as giving it — this reopens the banner.
50
+ export function CookieSettingsButton({ className }: { className?: string }) {
51
+ if (!COOKIE_BANNER_ENABLED) return null
52
+ return (
53
+ <button type="button" className={className} onClick={() => setCookieConsent(null)}>
54
+ Cookie settings
55
+ </button>
56
+ )
57
+ }
@@ -2,7 +2,7 @@ import * as React from "react"
2
2
  import { cva, type VariantProps } from "class-variance-authority"
3
3
  import { Slot } from "radix-ui"
4
4
 
5
- import { cn } from "@/lib/utils"
5
+ import { cn } from "../../lib/utils"
6
6
 
7
7
  const buttonVariants = cva(
8
8
  "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -0,0 +1,178 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Camera, ImagePlus, Upload, X } from "lucide-react"
5
+
6
+ import { Button } from "./button"
7
+ import { cn } from "../../lib/utils"
8
+
9
+ type FileUploadProps = {
10
+ /** Take a photo with the device camera (rear camera on mobile; a file picker on desktop). */
11
+ camera?: boolean
12
+ /** Pick existing files from the gallery / file system. */
13
+ gallery?: boolean
14
+ /** Drop files onto the component. */
15
+ dragDrop?: boolean
16
+ /** Same format as `<input accept>`. Also enforced on dropped files. */
17
+ accept?: string
18
+ multiple?: boolean
19
+ maxFiles?: number
20
+ maxSizeMB?: number
21
+ disabled?: boolean
22
+ onFilesChange?: (files: File[]) => void
23
+ className?: string
24
+ }
25
+
26
+ function matchesAccept(file: File, accept: string) {
27
+ return accept.split(",").some((raw) => {
28
+ const rule = raw.trim().toLowerCase()
29
+ if (!rule) return false
30
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule)
31
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1))
32
+ return file.type === rule
33
+ })
34
+ }
35
+
36
+ function FileUpload({
37
+ camera = false,
38
+ gallery = false,
39
+ dragDrop = false,
40
+ accept = "image/*",
41
+ multiple = true,
42
+ maxFiles,
43
+ maxSizeMB,
44
+ disabled = false,
45
+ onFilesChange,
46
+ className,
47
+ }: FileUploadProps) {
48
+ const [files, setFiles] = React.useState<File[]>([])
49
+ const [error, setError] = React.useState<string | null>(null)
50
+ const [dragging, setDragging] = React.useState(false)
51
+ const cameraRef = React.useRef<HTMLInputElement>(null)
52
+ const galleryRef = React.useRef<HTMLInputElement>(null)
53
+
54
+ const previews = React.useMemo(
55
+ () => files.map((f) => (f.type.startsWith("image/") ? URL.createObjectURL(f) : null)),
56
+ [files]
57
+ )
58
+ React.useEffect(() => () => previews.forEach((url) => url && URL.revokeObjectURL(url)), [previews])
59
+
60
+ const update = (next: File[]) => {
61
+ setFiles(next)
62
+ onFilesChange?.(next)
63
+ }
64
+
65
+ const add = (incoming: File[]) => {
66
+ const limit = multiple ? (maxFiles ?? Infinity) : 1
67
+ const accepted: File[] = []
68
+ let rejected: string | null = null
69
+ for (const file of incoming) {
70
+ if (!matchesAccept(file, accept)) rejected = `${file.name} is not an accepted file type.`
71
+ else if (maxSizeMB && file.size > maxSizeMB * 1024 * 1024) rejected = `${file.name} is larger than ${maxSizeMB} MB.`
72
+ else accepted.push(file)
73
+ }
74
+ const base = multiple ? files : []
75
+ const next = [...base, ...accepted].slice(0, limit)
76
+ if (base.length + accepted.length > limit) rejected = `You can upload up to ${limit} file${limit === 1 ? "" : "s"}.`
77
+ setError(rejected)
78
+ if (accepted.length) update(next)
79
+ }
80
+
81
+ const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
82
+ add(Array.from(e.target.files ?? []))
83
+ e.target.value = "" // allow picking the same file again
84
+ }
85
+
86
+ const remove = (index: number) => {
87
+ setError(null)
88
+ update(files.filter((_, i) => i !== index))
89
+ }
90
+
91
+ if (!camera && !gallery && !dragDrop) return null
92
+
93
+ const buttons = (
94
+ <div className="flex flex-wrap justify-center gap-2">
95
+ {camera && (
96
+ <Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => cameraRef.current?.click()}>
97
+ <Camera /> Take photo
98
+ </Button>
99
+ )}
100
+ {gallery && (
101
+ <Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => galleryRef.current?.click()}>
102
+ <ImagePlus /> Choose from gallery
103
+ </Button>
104
+ )}
105
+ </div>
106
+ )
107
+
108
+ return (
109
+ <div className={cn("space-y-3", className)}>
110
+ {camera && (
111
+ <input ref={cameraRef} type="file" accept={accept} capture="environment" hidden disabled={disabled} onChange={onInput} />
112
+ )}
113
+ {gallery && (
114
+ <input ref={galleryRef} type="file" accept={accept} multiple={multiple} hidden disabled={disabled} onChange={onInput} />
115
+ )}
116
+
117
+ {dragDrop ? (
118
+ <div
119
+ onDragOver={(e) => {
120
+ e.preventDefault()
121
+ if (!disabled) setDragging(true)
122
+ }}
123
+ onDragLeave={() => setDragging(false)}
124
+ onDrop={(e) => {
125
+ e.preventDefault()
126
+ setDragging(false)
127
+ if (!disabled) add(Array.from(e.dataTransfer.files))
128
+ }}
129
+ aria-disabled={disabled}
130
+ className={cn(
131
+ "flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-6 text-center text-sm text-muted-foreground transition-colors",
132
+ dragging && "border-primary bg-primary/5",
133
+ disabled && "opacity-50"
134
+ )}
135
+ >
136
+ <Upload className="size-6" />
137
+ <p>Drag and drop {multiple ? "files" : "a file"} here</p>
138
+ {buttons}
139
+ </div>
140
+ ) : (
141
+ buttons
142
+ )}
143
+
144
+ {error && (
145
+ <p role="alert" className="text-sm text-destructive">
146
+ {error}
147
+ </p>
148
+ )}
149
+
150
+ {files.length > 0 && (
151
+ <ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
152
+ {files.map((file, i) => (
153
+ <li key={`${file.name}-${file.lastModified}-${i}`} className="relative aspect-square overflow-hidden rounded-md border bg-muted">
154
+ {previews[i] ? (
155
+ // eslint-disable-next-line @next/next/no-img-element -- blob: URLs can't go through next/image
156
+ <img src={previews[i]!} alt={file.name} className="size-full object-cover" />
157
+ ) : (
158
+ <span className="flex size-full items-center justify-center break-all p-2 text-xs">{file.name}</span>
159
+ )}
160
+ <button
161
+ type="button"
162
+ onClick={() => remove(i)}
163
+ disabled={disabled}
164
+ aria-label={`Remove ${file.name}`}
165
+ className="absolute top-1 right-1 rounded-full bg-background/80 p-1 text-foreground hover:bg-background"
166
+ >
167
+ <X className="size-3" />
168
+ </button>
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ )}
173
+ </div>
174
+ )
175
+ }
176
+
177
+ export { FileUpload, matchesAccept }
178
+ export type { FileUploadProps }
@@ -1,6 +1,6 @@
1
1
  import * as React from "react"
2
2
 
3
- import { cn } from "@/lib/utils"
3
+ import { cn } from "../../lib/utils"
4
4
 
5
5
  function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
6
  return (
@@ -3,7 +3,7 @@
3
3
  import * as React from "react"
4
4
  import { Separator as SeparatorPrimitive } from "radix-ui"
5
5
 
6
- import { cn } from "@/lib/utils"
6
+ import { cn } from "../../lib/utils"
7
7
 
8
8
  function Separator({
9
9
  className,
@@ -4,7 +4,7 @@ import * as React from "react"
4
4
  import { XIcon } from "lucide-react"
5
5
  import { Dialog as SheetPrimitive } from "radix-ui"
6
6
 
7
- import { cn } from "@/lib/utils"
7
+ import { cn } from "../../lib/utils"
8
8
 
9
9
  function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
10
10
  return <SheetPrimitive.Root data-slot="sheet" {...props} />