@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.
- package/README.md +56 -322
- package/components/dashboard/admin/adminUsersTable.tsx +36 -2
- package/components/dashboard/admin/feedbackTestForm.tsx +55 -0
- package/components/dashboard/admin/systemSettingsForm.tsx +28 -0
- package/components/dashboard/appSidebar.tsx +4 -4
- package/components/dashboard/errorFeedback.tsx +65 -0
- package/components/dashboard/feedbackDialog.tsx +160 -0
- package/components/dashboard/installAppCard.tsx +187 -0
- package/components/site/cookie-banner.tsx +57 -0
- package/components/ui/button.tsx +1 -1
- package/components/ui/file-upload.tsx +178 -0
- package/components/ui/input.tsx +1 -1
- package/components/ui/separator.tsx +1 -1
- package/components/ui/sheet.tsx +1 -1
- package/components/ui/sidebar.tsx +8 -8
- package/components/ui/skeleton.tsx +1 -1
- package/components/ui/tooltip.tsx +1 -1
- package/dist/activity/logger.js +21 -1
- package/dist/activity/logger.js.map +1 -1
- package/dist/activity/logger.mjs +4 -4
- package/dist/activity/queries.d.mts +1 -1
- package/dist/activity/queries.d.ts +1 -1
- package/dist/activity/queries.js +21 -1
- package/dist/activity/queries.js.map +1 -1
- package/dist/activity/queries.mjs +3 -3
- package/dist/admin/queries.js +21 -1
- package/dist/admin/queries.js.map +1 -1
- package/dist/admin/queries.mjs +3 -3
- package/dist/api/feedback.d.mts +23 -0
- package/dist/api/feedback.d.ts +23 -0
- package/dist/api/feedback.js +1224 -0
- package/dist/api/feedback.js.map +1 -0
- package/dist/api/feedback.mjs +136 -0
- package/dist/api/feedback.mjs.map +1 -0
- package/dist/auth/helpers.d.mts +3 -3
- package/dist/auth/helpers.d.ts +3 -3
- package/dist/auth/helpers.js +24 -3
- package/dist/auth/helpers.js.map +1 -1
- package/dist/auth/helpers.mjs +7 -7
- package/dist/auth/helpers.mjs.map +1 -1
- package/dist/auth/index.js +23 -2
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/index.mjs +6 -6
- package/dist/auth-client/index.d.mts +16 -16
- package/dist/auth-client/index.d.ts +16 -16
- package/dist/{chunk-IF3JZRSZ.mjs → chunk-3MGEAGHU.mjs} +6 -5
- package/dist/chunk-3MGEAGHU.mjs.map +1 -0
- package/dist/chunk-KAEA35OC.mjs +76 -0
- package/dist/chunk-KAEA35OC.mjs.map +1 -0
- package/dist/{chunk-GTPTVNY2.mjs → chunk-TMJYAOC2.mjs} +10 -2
- package/dist/chunk-TMJYAOC2.mjs.map +1 -0
- package/dist/{chunk-OHAUKE2Q.mjs → chunk-VQ5B4MA7.mjs} +4 -4
- package/dist/{chunk-OHAUKE2Q.mjs.map → chunk-VQ5B4MA7.mjs.map} +1 -1
- package/dist/{chunk-5PYDAD4P.mjs → chunk-WZDAMCUX.mjs} +4 -4
- package/dist/{chunk-5PYDAD4P.mjs.map → chunk-WZDAMCUX.mjs.map} +1 -1
- package/dist/{chunk-7Q5T4NZ7.mjs → chunk-XWVAJXAG.mjs} +9 -9
- package/dist/chunk-XWVAJXAG.mjs.map +1 -0
- package/dist/{chunk-4B7MJOLH.mjs → chunk-ZQCALTKB.mjs} +17 -5
- package/dist/chunk-ZQCALTKB.mjs.map +1 -0
- package/dist/config/env.d.mts +3 -0
- package/dist/config/env.d.ts +3 -0
- package/dist/config/env.js +8 -0
- package/dist/config/env.js.map +1 -1
- package/dist/config/env.mjs +1 -1
- package/dist/db/index.d.mts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +21 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/index.mjs +3 -3
- package/dist/db/schema.d.mts +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +13 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/db/schema.mjs +1 -1
- package/dist/errors.d.mts +29 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +365 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +7 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/{schema-BrBmQFkn.d.mts → schema-BNSBWNjK.d.mts} +98 -5
- package/dist/{schema-BrBmQFkn.d.ts → schema-BNSBWNjK.d.ts} +98 -5
- package/dist/sessions/queries.js +21 -1
- package/dist/sessions/queries.js.map +1 -1
- package/dist/sessions/queries.mjs +3 -3
- package/dist/settings/queries.d.mts +2 -2
- package/dist/settings/queries.d.ts +2 -2
- package/dist/settings/queries.js +22 -1
- package/dist/settings/queries.js.map +1 -1
- package/dist/settings/queries.mjs +4 -4
- package/dist/users/queries.d.mts +3 -3
- package/dist/users/queries.d.ts +3 -3
- package/dist/users/queries.js +21 -1
- package/dist/users/queries.js.map +1 -1
- package/dist/users/queries.mjs +3 -3
- package/hooks/use-mobile.ts +19 -0
- package/lib/device-fingerprint.ts +2 -3
- package/package.json +35 -32
- package/dist/chunk-4B7MJOLH.mjs.map +0 -1
- package/dist/chunk-7Q5T4NZ7.mjs.map +0 -1
- package/dist/chunk-GTPTVNY2.mjs.map +0 -1
- 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 “Open as Web App” 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'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'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
|
+
}
|
package/components/ui/button.tsx
CHANGED
|
@@ -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 "
|
|
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 }
|
package/components/ui/input.tsx
CHANGED
package/components/ui/sheet.tsx
CHANGED
|
@@ -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 "
|
|
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} />
|