@usequark/quark-create-app 1.23.11
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 +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
const CHARS = " .:-=+*#%@";
|
|
6
|
+
const W = 110;
|
|
7
|
+
const H = 55;
|
|
8
|
+
|
|
9
|
+
const CONF = {
|
|
10
|
+
zoom: 1.16,
|
|
11
|
+
speed: 1.7,
|
|
12
|
+
dashLength: 16,
|
|
13
|
+
dashThickness: 2.8,
|
|
14
|
+
dashDistFactor: 0.36,
|
|
15
|
+
ringGapMultiplier: 0.3,
|
|
16
|
+
angleLimit: 0.3,
|
|
17
|
+
yBounce: 1,
|
|
18
|
+
ringRadius: 13,
|
|
19
|
+
ringThickness: 2.6,
|
|
20
|
+
yScaleFactor: 1.05,
|
|
21
|
+
dashAngle: 0.8,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Fixed brand palette - all three colors are part of the Quark logo identity.
|
|
25
|
+
const CLASS_COLORS = { s: "#2d3436", b: "#377dff", c: "#ff4757" };
|
|
26
|
+
|
|
27
|
+
export default function QuarkAnimation() {
|
|
28
|
+
const ref = useRef(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const el = ref.current;
|
|
32
|
+
if (!el) return;
|
|
33
|
+
|
|
34
|
+
let t = 0;
|
|
35
|
+
let raf;
|
|
36
|
+
|
|
37
|
+
function render() {
|
|
38
|
+
t += 0.01 * CONF.speed;
|
|
39
|
+
|
|
40
|
+
const rY = Math.sin(t) * CONF.angleLimit;
|
|
41
|
+
const yO = Math.sin(t * 1.5) * CONF.yBounce;
|
|
42
|
+
const cB = new Array(W * H).fill(" ");
|
|
43
|
+
const clB = new Array(W * H).fill("");
|
|
44
|
+
const zB = new Float32Array(W * H).fill(-1000);
|
|
45
|
+
|
|
46
|
+
const rR = CONF.ringRadius * CONF.zoom;
|
|
47
|
+
const rT = CONF.ringThickness * CONF.zoom;
|
|
48
|
+
const dL = CONF.dashLength * CONF.zoom;
|
|
49
|
+
const dT = CONF.dashThickness * CONF.zoom;
|
|
50
|
+
const dA = CONF.dashAngle;
|
|
51
|
+
const gap = (dT / rR) * 0.8 + CONF.ringGapMultiplier;
|
|
52
|
+
|
|
53
|
+
// Ring (torus)
|
|
54
|
+
for (let p = 0; p < Math.PI * 2; p += 0.12) {
|
|
55
|
+
for (let th = 0; th < Math.PI * 2; th += 0.04) {
|
|
56
|
+
let d = Math.abs(th - dA);
|
|
57
|
+
if (d > Math.PI) d = 2 * Math.PI - d;
|
|
58
|
+
if (d < gap) continue;
|
|
59
|
+
|
|
60
|
+
const rXx = Math.cos(th);
|
|
61
|
+
const rYy = Math.sin(th);
|
|
62
|
+
const x = (rR + rT * Math.cos(p)) * rXx;
|
|
63
|
+
const y = (rR + rT * Math.cos(p)) * rYy * CONF.yScaleFactor;
|
|
64
|
+
const z = rT * Math.sin(p);
|
|
65
|
+
|
|
66
|
+
const rx = x * Math.cos(rY) + z * Math.sin(rY);
|
|
67
|
+
const rz = -x * Math.sin(rY) + z * Math.cos(rY);
|
|
68
|
+
const ry = y + yO;
|
|
69
|
+
|
|
70
|
+
const xp = Math.floor(W / 2 + rx * 1.5);
|
|
71
|
+
const yp = Math.floor(H / 2 + ry);
|
|
72
|
+
|
|
73
|
+
if (xp >= 0 && xp < W && yp >= 0 && yp < H) {
|
|
74
|
+
const i = yp * W + xp;
|
|
75
|
+
if (rz > zB[i]) {
|
|
76
|
+
zB[i] = rz;
|
|
77
|
+
const nT = ((th % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
78
|
+
const nD = ((dA % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
79
|
+
clB[i] = rYy < 0 ? "s" : nT > nD ? "b" : "c";
|
|
80
|
+
cB[i] =
|
|
81
|
+
CHARS[
|
|
82
|
+
Math.floor(
|
|
83
|
+
Math.max(0, Math.min(1, (rz + rT) / (rT * 2) + 0.3)) *
|
|
84
|
+
(CHARS.length - 1),
|
|
85
|
+
)
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dash (quark)
|
|
93
|
+
for (let l = 0; l < dL; l += 0.6) {
|
|
94
|
+
for (let p = 0; p < Math.PI * 2; p += 0.2) {
|
|
95
|
+
for (let rad = 0; rad < dT; rad += 0.6) {
|
|
96
|
+
const sR = rR * CONF.dashDistFactor;
|
|
97
|
+
const lx = rad * Math.cos(p);
|
|
98
|
+
const ly = rad * Math.sin(p);
|
|
99
|
+
const lz = l;
|
|
100
|
+
|
|
101
|
+
const tx =
|
|
102
|
+
sR * Math.cos(dA) + lz * Math.cos(dA) - lx * Math.sin(dA);
|
|
103
|
+
const ty =
|
|
104
|
+
(sR * Math.sin(dA) + lz * Math.sin(dA) + lx * Math.cos(dA)) *
|
|
105
|
+
CONF.yScaleFactor;
|
|
106
|
+
const tz = ly;
|
|
107
|
+
|
|
108
|
+
const rtx = tx * Math.cos(rY) + tz * Math.sin(rY);
|
|
109
|
+
const rtz = -tx * Math.sin(rY) + tz * Math.cos(rY);
|
|
110
|
+
const rty = ty + yO;
|
|
111
|
+
|
|
112
|
+
const xp = Math.floor(W / 2 + rtx * 1.5);
|
|
113
|
+
const yp = Math.floor(H / 2 + rty);
|
|
114
|
+
|
|
115
|
+
if (xp >= 0 && xp < W && yp >= 0 && yp < H) {
|
|
116
|
+
const i = yp * W + xp;
|
|
117
|
+
if (rtz > zB[i]) {
|
|
118
|
+
zB[i] = rtz;
|
|
119
|
+
clB[i] = "c";
|
|
120
|
+
cB[i] =
|
|
121
|
+
CHARS[
|
|
122
|
+
Math.floor(
|
|
123
|
+
Math.max(0, Math.min(1, (rtz + dT) / (dT * 2) + 0.3)) *
|
|
124
|
+
(CHARS.length - 1),
|
|
125
|
+
)
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build HTML string - inline colors avoid global CSS pollution
|
|
134
|
+
let o = "";
|
|
135
|
+
for (let i = 0; i < cB.length; i++) {
|
|
136
|
+
if (i > 0 && i % W === 0) o += "\n";
|
|
137
|
+
const cls = clB[i];
|
|
138
|
+
if (cls) {
|
|
139
|
+
o += `<span style="color:${CLASS_COLORS[cls]}">${cB[i]}</span>`;
|
|
140
|
+
} else {
|
|
141
|
+
o += cB[i];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
el.innerHTML = o;
|
|
146
|
+
raf = requestAnimationFrame(render);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render();
|
|
150
|
+
return () => cancelAnimationFrame(raf);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
ref={ref}
|
|
156
|
+
style={{
|
|
157
|
+
lineHeight: "0.82",
|
|
158
|
+
letterSpacing: "0",
|
|
159
|
+
whiteSpace: "pre",
|
|
160
|
+
textAlign: "center",
|
|
161
|
+
fontSize: "11px",
|
|
162
|
+
fontWeight: "bold",
|
|
163
|
+
fontFamily: "monospace",
|
|
164
|
+
color: "#e0e0e0",
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import AsciiPanel from "./AsciiPanel.js";
|
|
5
|
+
|
|
6
|
+
const INNER_W = 34;
|
|
7
|
+
const TYPING_MS = 2000;
|
|
8
|
+
const HOLD_MS = 5000;
|
|
9
|
+
const UNTYPING_MS = 1500;
|
|
10
|
+
const NUM_PANELS = 3;
|
|
11
|
+
|
|
12
|
+
const PHASE_INITIAL_TYPING = 0;
|
|
13
|
+
const PHASE_HOLD = 1;
|
|
14
|
+
const PHASE_UNTYPE_0 = 2;
|
|
15
|
+
const PHASE_TYPE_0 = 3;
|
|
16
|
+
const PHASE_HOLD_AFTER_0 = 4;
|
|
17
|
+
const PHASE_UNTYPE_1 = 5;
|
|
18
|
+
const PHASE_TYPE_1 = 6;
|
|
19
|
+
const PHASE_HOLD_AFTER_1 = 7;
|
|
20
|
+
const PHASE_UNTYPE_2 = 8;
|
|
21
|
+
const PHASE_TYPE_2 = 9;
|
|
22
|
+
const PHASE_HOLD_AFTER_2 = 10;
|
|
23
|
+
|
|
24
|
+
function wrapText(text, maxLen) {
|
|
25
|
+
const words = text.split(" ");
|
|
26
|
+
const lines = [];
|
|
27
|
+
let current = "";
|
|
28
|
+
for (const word of words) {
|
|
29
|
+
if (current.length + (current ? 1 : 0) + word.length > maxLen) {
|
|
30
|
+
if (current) lines.push(current);
|
|
31
|
+
current = word.length > maxLen ? word.slice(0, maxLen) : word;
|
|
32
|
+
} else {
|
|
33
|
+
current = current ? `${current} ${word}` : word;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (current) lines.push(current);
|
|
37
|
+
return lines.length ? lines : [""];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getContentLines(panel) {
|
|
41
|
+
const t = wrapText(panel.title, INNER_W);
|
|
42
|
+
const d = wrapText(panel.description, INNER_W);
|
|
43
|
+
return [...t, "", ...d];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getMaxDisplayLines(panels) {
|
|
47
|
+
return Math.max(...panels.map((panel) => getContentLines(panel).length));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getTotalChars(panel) {
|
|
51
|
+
const t = wrapText(panel.title, INNER_W);
|
|
52
|
+
const d = wrapText(panel.description, INNER_W);
|
|
53
|
+
return t.join("").length + d.join("").length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PHASE_DURATION = {
|
|
57
|
+
[PHASE_INITIAL_TYPING]: TYPING_MS,
|
|
58
|
+
[PHASE_HOLD]: HOLD_MS,
|
|
59
|
+
[PHASE_UNTYPE_0]: UNTYPING_MS,
|
|
60
|
+
[PHASE_TYPE_0]: TYPING_MS,
|
|
61
|
+
[PHASE_HOLD_AFTER_0]: HOLD_MS,
|
|
62
|
+
[PHASE_UNTYPE_1]: UNTYPING_MS,
|
|
63
|
+
[PHASE_TYPE_1]: TYPING_MS,
|
|
64
|
+
[PHASE_HOLD_AFTER_1]: HOLD_MS,
|
|
65
|
+
[PHASE_UNTYPE_2]: UNTYPING_MS,
|
|
66
|
+
[PHASE_TYPE_2]: TYPING_MS,
|
|
67
|
+
[PHASE_HOLD_AFTER_2]: HOLD_MS,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const NEXT_PHASE = {
|
|
71
|
+
[PHASE_INITIAL_TYPING]: PHASE_HOLD,
|
|
72
|
+
[PHASE_HOLD]: PHASE_UNTYPE_0,
|
|
73
|
+
[PHASE_UNTYPE_0]: PHASE_TYPE_0,
|
|
74
|
+
[PHASE_TYPE_0]: PHASE_HOLD_AFTER_0,
|
|
75
|
+
[PHASE_HOLD_AFTER_0]: PHASE_UNTYPE_1,
|
|
76
|
+
[PHASE_UNTYPE_1]: PHASE_TYPE_1,
|
|
77
|
+
[PHASE_TYPE_1]: PHASE_HOLD_AFTER_1,
|
|
78
|
+
[PHASE_HOLD_AFTER_1]: PHASE_UNTYPE_2,
|
|
79
|
+
[PHASE_UNTYPE_2]: PHASE_TYPE_2,
|
|
80
|
+
[PHASE_TYPE_2]: PHASE_HOLD_AFTER_2,
|
|
81
|
+
[PHASE_HOLD_AFTER_2]: PHASE_UNTYPE_0,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default function SyncedPanels({ panels }) {
|
|
85
|
+
const [renderTick, setRenderTick] = useState(0);
|
|
86
|
+
const [copiedIdx, setCopiedIdx] = useState(-1);
|
|
87
|
+
const rafRef = useRef(null);
|
|
88
|
+
|
|
89
|
+
const stateRef = useRef({
|
|
90
|
+
phase: PHASE_INITIAL_TYPING,
|
|
91
|
+
phaseStart: 0,
|
|
92
|
+
started: false,
|
|
93
|
+
promptIdxs: [0, 1, 2],
|
|
94
|
+
charCounts: [0, 0, 0],
|
|
95
|
+
queue: Array.from({ length: panels.length }, (_, i) => i).slice(NUM_PANELS),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const maxLines = getMaxDisplayLines(panels.slice(0, NUM_PANELS));
|
|
99
|
+
const maxCharsRef = useRef(panels.map(getTotalChars));
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
maxCharsRef.current = panels.map(getTotalChars);
|
|
103
|
+
}, [panels]);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
let active = true;
|
|
107
|
+
|
|
108
|
+
function tick(ts) {
|
|
109
|
+
if (!active) return;
|
|
110
|
+
|
|
111
|
+
const st = stateRef.current;
|
|
112
|
+
if (!st.started) {
|
|
113
|
+
st.started = true;
|
|
114
|
+
st.phaseStart = ts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const elapsed = ts - st.phaseStart;
|
|
118
|
+
const duration = PHASE_DURATION[st.phase];
|
|
119
|
+
const t = Math.min(elapsed / duration, 1);
|
|
120
|
+
const p = panels;
|
|
121
|
+
|
|
122
|
+
const isTyping =
|
|
123
|
+
st.phase === PHASE_INITIAL_TYPING ||
|
|
124
|
+
st.phase === PHASE_TYPE_0 ||
|
|
125
|
+
st.phase === PHASE_TYPE_1 ||
|
|
126
|
+
st.phase === PHASE_TYPE_2;
|
|
127
|
+
const isUntyping =
|
|
128
|
+
st.phase === PHASE_UNTYPE_0 ||
|
|
129
|
+
st.phase === PHASE_UNTYPE_1 ||
|
|
130
|
+
st.phase === PHASE_UNTYPE_2;
|
|
131
|
+
|
|
132
|
+
const changingPanel =
|
|
133
|
+
st.phase === PHASE_UNTYPE_0 || st.phase === PHASE_TYPE_0
|
|
134
|
+
? 0
|
|
135
|
+
: st.phase === PHASE_UNTYPE_1 || st.phase === PHASE_TYPE_1
|
|
136
|
+
? 1
|
|
137
|
+
: st.phase === PHASE_UNTYPE_2 || st.phase === PHASE_TYPE_2
|
|
138
|
+
? 2
|
|
139
|
+
: -1;
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < NUM_PANELS; i++) {
|
|
142
|
+
if (isTyping && changingPanel === i) {
|
|
143
|
+
const maxChars =
|
|
144
|
+
maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
|
|
145
|
+
st.charCounts[i] = Math.floor(t * maxChars);
|
|
146
|
+
} else if (isUntyping && changingPanel === i) {
|
|
147
|
+
const maxChars =
|
|
148
|
+
maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
|
|
149
|
+
st.charCounts[i] = Math.floor((1 - t) * maxChars);
|
|
150
|
+
} else if (isTyping && changingPanel === -1) {
|
|
151
|
+
const maxChars =
|
|
152
|
+
maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
|
|
153
|
+
st.charCounts[i] = Math.floor(t * maxChars);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (t >= 1) {
|
|
158
|
+
if (isUntyping && changingPanel >= 0) {
|
|
159
|
+
if (st.queue.length === 0) {
|
|
160
|
+
st.queue = Array.from({ length: p.length }, (_, i) => i).filter(
|
|
161
|
+
(idx) => !st.promptIdxs.includes(idx),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
st.promptIdxs[changingPanel] = st.queue.shift();
|
|
165
|
+
st.charCounts[changingPanel] = 0;
|
|
166
|
+
}
|
|
167
|
+
st.phase = NEXT_PHASE[st.phase];
|
|
168
|
+
st.phaseStart = ts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
setRenderTick((n) => n + 1);
|
|
172
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
176
|
+
return () => {
|
|
177
|
+
active = false;
|
|
178
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
179
|
+
};
|
|
180
|
+
}, [panels]);
|
|
181
|
+
|
|
182
|
+
void renderTick;
|
|
183
|
+
|
|
184
|
+
function handleCopy(text, idx) {
|
|
185
|
+
navigator.clipboard.writeText(text);
|
|
186
|
+
setCopiedIdx(idx);
|
|
187
|
+
setTimeout(() => setCopiedIdx(-1), 1500);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const st = stateRef.current;
|
|
191
|
+
const p = panels;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="quark-home-panels">
|
|
195
|
+
{[0, 1, 2].map((i) => {
|
|
196
|
+
const panel = p[st.promptIdxs[i] % p.length];
|
|
197
|
+
return (
|
|
198
|
+
<AsciiPanel
|
|
199
|
+
key={`panel-${i}`}
|
|
200
|
+
title={panel.title}
|
|
201
|
+
description={panel.description}
|
|
202
|
+
prompt={panel.prompt}
|
|
203
|
+
charCount={st.charCounts[i]}
|
|
204
|
+
copied={copiedIdx === i}
|
|
205
|
+
onCopy={() => handleCopy(panel.prompt, i)}
|
|
206
|
+
maxLines={maxLines}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { useEffect, useEffectEvent, useRef } from "react";
|
|
5
|
+
import { isUmamiStaff } from "../../lib/analytics/umami.js";
|
|
6
|
+
import { getUmamiConfig } from "../../lib/analytics/umami-config.js";
|
|
7
|
+
import {
|
|
8
|
+
buildReplayEndpoint,
|
|
9
|
+
createReplayBuffer,
|
|
10
|
+
getReplayMaskConfig,
|
|
11
|
+
postReplayBatch,
|
|
12
|
+
shouldSampleReplay,
|
|
13
|
+
UMAMI_REPLAY_DEFAULTS,
|
|
14
|
+
waitForUmamiSessionCache,
|
|
15
|
+
} from "../../lib/analytics/umami-replay.js";
|
|
16
|
+
|
|
17
|
+
const RRWEB_OPTIONS = {
|
|
18
|
+
inlineStylesheet: true,
|
|
19
|
+
slimDOMOptions: {
|
|
20
|
+
script: true,
|
|
21
|
+
comment: true,
|
|
22
|
+
headMetaDescKeywords: true,
|
|
23
|
+
headMetaSocial: true,
|
|
24
|
+
headMetaRobots: true,
|
|
25
|
+
headMetaHttpEquiv: true,
|
|
26
|
+
headMetaAuthorship: true,
|
|
27
|
+
headMetaVerification: true,
|
|
28
|
+
},
|
|
29
|
+
recordCanvas: false,
|
|
30
|
+
recordCrossOriginIframes: false,
|
|
31
|
+
checkoutEveryNms: 30000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default function UmamiReplayRecorder() {
|
|
35
|
+
const pathname = usePathname();
|
|
36
|
+
const previousPathnameRef = useRef(pathname);
|
|
37
|
+
const bufferRef = useRef(createReplayBuffer());
|
|
38
|
+
const cacheRef = useRef("");
|
|
39
|
+
const endpointRef = useRef("");
|
|
40
|
+
const websiteIdRef = useRef("");
|
|
41
|
+
const startedAtRef = useRef(0);
|
|
42
|
+
const isActiveRef = useRef(false);
|
|
43
|
+
const recordRef = useRef(null);
|
|
44
|
+
const stopRecordingRef = useRef(null);
|
|
45
|
+
|
|
46
|
+
const flushPendingEvents = useEffectEvent(
|
|
47
|
+
async ({ useKeepalive = false } = {}) => {
|
|
48
|
+
const events = bufferRef.current.drain();
|
|
49
|
+
if (!events.length) return false;
|
|
50
|
+
|
|
51
|
+
const sent = await postReplayBatch({
|
|
52
|
+
endpoint: endpointRef.current,
|
|
53
|
+
cache: cacheRef.current,
|
|
54
|
+
websiteId: websiteIdRef.current,
|
|
55
|
+
events,
|
|
56
|
+
useKeepalive,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!sent) {
|
|
60
|
+
bufferRef.current.restore(events);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return sent;
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const umamiConfig = getUmamiConfig();
|
|
69
|
+
if (!umamiConfig.replayEnabled || !shouldSampleReplay()) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip replay on admin pages and for any staff user (umami_user_role
|
|
74
|
+
// cookie set by admin layout via inline client script).
|
|
75
|
+
if (window.location.pathname.startsWith("/admin")) return undefined;
|
|
76
|
+
if (isUmamiStaff()) return undefined;
|
|
77
|
+
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
const abortController = new AbortController();
|
|
80
|
+
let flushIntervalId = null;
|
|
81
|
+
let stopRecording = () => {};
|
|
82
|
+
let removeVisibilityListener = () => {};
|
|
83
|
+
let removePageHideListener = () => {};
|
|
84
|
+
|
|
85
|
+
async function setupReplay() {
|
|
86
|
+
const cache = await waitForUmamiSessionCache({
|
|
87
|
+
signal: abortController.signal,
|
|
88
|
+
});
|
|
89
|
+
if (cancelled || !cache) return;
|
|
90
|
+
|
|
91
|
+
const { record } = await import("rrweb");
|
|
92
|
+
if (cancelled) return;
|
|
93
|
+
|
|
94
|
+
cacheRef.current = cache;
|
|
95
|
+
endpointRef.current = buildReplayEndpoint(umamiConfig.url);
|
|
96
|
+
websiteIdRef.current = umamiConfig.websiteId;
|
|
97
|
+
startedAtRef.current = Date.now();
|
|
98
|
+
|
|
99
|
+
const stopRrweb = record({
|
|
100
|
+
emit(event) {
|
|
101
|
+
if (
|
|
102
|
+
Date.now() - startedAtRef.current >
|
|
103
|
+
UMAMI_REPLAY_DEFAULTS.maxDurationMs
|
|
104
|
+
) {
|
|
105
|
+
stopRecording();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const eventCount = bufferRef.current.push(event);
|
|
110
|
+
if (eventCount >= UMAMI_REPLAY_DEFAULTS.flushEventCount) {
|
|
111
|
+
void flushPendingEvents();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
...getReplayMaskConfig(),
|
|
115
|
+
...RRWEB_OPTIONS,
|
|
116
|
+
...(UMAMI_REPLAY_DEFAULTS.blockSelector
|
|
117
|
+
? { blockSelector: UMAMI_REPLAY_DEFAULTS.blockSelector }
|
|
118
|
+
: {}),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
recordRef.current = record;
|
|
122
|
+
isActiveRef.current = true;
|
|
123
|
+
flushIntervalId = window.setInterval(() => {
|
|
124
|
+
void flushPendingEvents();
|
|
125
|
+
}, UMAMI_REPLAY_DEFAULTS.flushIntervalMs);
|
|
126
|
+
|
|
127
|
+
const handleVisibilityChange = () => {
|
|
128
|
+
if (document.visibilityState === "hidden") {
|
|
129
|
+
void flushPendingEvents({ useKeepalive: true });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const handlePageHide = () => {
|
|
133
|
+
void flushPendingEvents({ useKeepalive: true });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
137
|
+
window.addEventListener("pagehide", handlePageHide);
|
|
138
|
+
|
|
139
|
+
removeVisibilityListener = () => {
|
|
140
|
+
document.removeEventListener(
|
|
141
|
+
"visibilitychange",
|
|
142
|
+
handleVisibilityChange,
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
removePageHideListener = () => {
|
|
146
|
+
window.removeEventListener("pagehide", handlePageHide);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const stopFn = ({ useKeepalive = false } = {}) => {
|
|
150
|
+
if (!isActiveRef.current) return;
|
|
151
|
+
|
|
152
|
+
isActiveRef.current = false;
|
|
153
|
+
if (flushIntervalId) {
|
|
154
|
+
clearInterval(flushIntervalId);
|
|
155
|
+
flushIntervalId = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
removeVisibilityListener();
|
|
159
|
+
removePageHideListener();
|
|
160
|
+
stopRrweb();
|
|
161
|
+
recordRef.current = null;
|
|
162
|
+
void flushPendingEvents({ useKeepalive });
|
|
163
|
+
};
|
|
164
|
+
stopRecordingRef.current = stopFn;
|
|
165
|
+
stopRecording = stopFn;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
void setupReplay();
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
cancelled = true;
|
|
172
|
+
abortController.abort();
|
|
173
|
+
stopRecording({ useKeepalive: true });
|
|
174
|
+
};
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (previousPathnameRef.current === pathname) return;
|
|
179
|
+
|
|
180
|
+
previousPathnameRef.current = pathname;
|
|
181
|
+
|
|
182
|
+
// Stop recording if navigating into an admin page or if a staff
|
|
183
|
+
// cookie appears mid-session (e.g. after visiting /admin).
|
|
184
|
+
if (pathname.startsWith("/admin") || isUmamiStaff()) {
|
|
185
|
+
if (isActiveRef.current) {
|
|
186
|
+
stopRecordingRef.current?.({ useKeepalive: true });
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!isActiveRef.current || !recordRef.current) return;
|
|
192
|
+
|
|
193
|
+
void flushPendingEvents();
|
|
194
|
+
recordRef.current.takeFullSnapshot?.(true);
|
|
195
|
+
}, [pathname]);
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { isUmamiStaff } from "../../lib/analytics/umami.js";
|
|
5
|
+
import { getUmamiConfig } from "../../lib/analytics/umami-config.js";
|
|
6
|
+
import { observeWebVitals } from "../../lib/analytics/umami-web-vitals.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client component that mounts the anonymous Core Web Vitals observer.
|
|
10
|
+
*
|
|
11
|
+
* Must be rendered inside the root layout's `<body>` so that
|
|
12
|
+
* `window` and `performance` APIs are available.
|
|
13
|
+
*
|
|
14
|
+
* Design:
|
|
15
|
+
* - Noop when Umami analytics is not configured or when
|
|
16
|
+
* the browser does not support PerformanceObserver.
|
|
17
|
+
* - Observes LCP, FID, CLS, INP, and TTFB and dispatches
|
|
18
|
+
* them as anonymous Umami custom events (event_type = 2).
|
|
19
|
+
* - Zero PII — only metric name, value, rating, and sanitised
|
|
20
|
+
* URL path are sent.
|
|
21
|
+
* - Cleans up all observers on unmount.
|
|
22
|
+
*/
|
|
23
|
+
export default function UmamiWebVitals() {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const config = getUmamiConfig();
|
|
26
|
+
if (!config.enabled) return;
|
|
27
|
+
|
|
28
|
+
// Skip web vitals dispatch for any staff user (identified by the
|
|
29
|
+
// umami_user_role cookie set by the admin layout).
|
|
30
|
+
if (isUmamiStaff()) return;
|
|
31
|
+
|
|
32
|
+
const stopObserving = observeWebVitals();
|
|
33
|
+
return () => {
|
|
34
|
+
stopObserving();
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { handlers } from "@/lib/auth";
|
|
2
|
+
import { normalizeAuthRequest } from "@/lib/normalize-auth-request";
|
|
3
|
+
|
|
4
|
+
export const GET = (req, context) =>
|
|
5
|
+
handlers.GET(normalizeAuthRequest(req), context);
|
|
6
|
+
export const POST = (req, context) =>
|
|
7
|
+
handlers.POST(normalizeAuthRequest(req), context);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { validateBody } from "@techstream/quark-core";
|
|
2
|
+
import { user } from "@techstream/quark-db";
|
|
3
|
+
import { importJWK, jwtVerify } from "jose";
|
|
4
|
+
import { NextResponse } from "next/server";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { issueTokenPair } from "../../../../lib/jwt";
|
|
7
|
+
import { handleError } from "../../error-handler";
|
|
8
|
+
|
|
9
|
+
const appleAuthSchema = z.object({
|
|
10
|
+
identityToken: z.string().min(1),
|
|
11
|
+
nonce: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* POST /api/auth/apple
|
|
18
|
+
* Verify Apple identity token and issue a custom JWT.
|
|
19
|
+
*/
|
|
20
|
+
export async function POST(request) {
|
|
21
|
+
try {
|
|
22
|
+
const { identityToken } = await validateBody(request, appleAuthSchema);
|
|
23
|
+
|
|
24
|
+
// Decode the JWT header to get the kid
|
|
25
|
+
const [headerB64] = identityToken.split(".");
|
|
26
|
+
const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
|
|
27
|
+
|
|
28
|
+
// Fetch Apple's public keys
|
|
29
|
+
const keysResponse = await fetch(APPLE_KEYS_URL);
|
|
30
|
+
const { keys } = await keysResponse.json();
|
|
31
|
+
|
|
32
|
+
// Find the matching key
|
|
33
|
+
const matchingKey = keys.find((k) => k.kid === header.kid);
|
|
34
|
+
if (!matchingKey) {
|
|
35
|
+
return NextResponse.json(
|
|
36
|
+
{ message: "Invalid Apple token: no matching key" },
|
|
37
|
+
{ status: 401 },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Verify the token using Apple's public key
|
|
42
|
+
const publicKey = await importJWK(matchingKey, "RS256");
|
|
43
|
+
|
|
44
|
+
let payload;
|
|
45
|
+
try {
|
|
46
|
+
const result = await jwtVerify(identityToken, publicKey, {
|
|
47
|
+
issuer: "https://appleid.apple.com",
|
|
48
|
+
});
|
|
49
|
+
payload = result.payload;
|
|
50
|
+
} catch {
|
|
51
|
+
return NextResponse.json(
|
|
52
|
+
{ message: "Invalid Apple token" },
|
|
53
|
+
{ status: 401 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const appleEmail = payload.email;
|
|
58
|
+
if (!appleEmail) {
|
|
59
|
+
return NextResponse.json(
|
|
60
|
+
{ message: "Apple token does not contain an email" },
|
|
61
|
+
{ status: 400 },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Find or create user
|
|
66
|
+
let existingUser = await user.findByEmail(appleEmail);
|
|
67
|
+
if (!existingUser) {
|
|
68
|
+
existingUser = await user.create({
|
|
69
|
+
email: appleEmail,
|
|
70
|
+
name: payload.name
|
|
71
|
+
? `${payload.name.firstName || ""} ${payload.name.lastName || ""}`.trim()
|
|
72
|
+
: null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { token, refreshToken, expiresAt } =
|
|
77
|
+
await issueTokenPair(existingUser);
|
|
78
|
+
return NextResponse.json({ token, refreshToken, expiresAt });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return handleError(error);
|
|
81
|
+
}
|
|
82
|
+
}
|