@techstream/quark-create-app 1.8.0 → 1.10.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 +2 -2
- package/package.json +3 -3
- package/src/index.js +415 -150
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.github/copilot-instructions.md +55 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/CLAUDE.md +273 -0
- package/templates/base-project/README.md +72 -30
- package/templates/base-project/apps/web/next.config.js +5 -1
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/public/quark.svg +46 -0
- package/templates/base-project/apps/web/railway.json +2 -2
- 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/api/health/route.js +56 -17
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +53 -0
- package/templates/base-project/apps/web/src/app/globals.css +121 -15
- package/templates/base-project/apps/web/src/app/icon.svg +46 -0
- package/templates/base-project/apps/web/src/app/layout.js +1 -0
- package/templates/base-project/apps/web/src/app/not-found.js +35 -0
- package/templates/base-project/apps/web/src/app/page.js +38 -5
- package/templates/base-project/apps/web/src/lib/theme.js +23 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -2
- package/templates/base-project/package.json +16 -1
- package/templates/base-project/packages/db/package.json +4 -4
- package/templates/base-project/packages/db/src/client.js +6 -1
- package/templates/base-project/packages/db/src/index.js +1 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/scripts/doctor.js +261 -0
- package/templates/base-project/turbo.json +2 -1
- package/templates/config/package.json +1 -0
- package/templates/config/src/index.js +1 -3
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +2 -1
- package/templates/ui/README.md +67 -0
- package/templates/ui/package.json +1 -0
- package/templates/ui/src/badge.js +32 -0
- package/templates/ui/src/badge.test.js +42 -0
- package/templates/ui/src/button.js +64 -15
- package/templates/ui/src/button.test.js +34 -5
- package/templates/ui/src/card.js +58 -0
- package/templates/ui/src/card.test.js +59 -0
- package/templates/ui/src/checkbox.js +35 -0
- package/templates/ui/src/checkbox.test.js +35 -0
- package/templates/ui/src/dialog.js +139 -0
- package/templates/ui/src/dialog.test.js +15 -0
- package/templates/ui/src/index.js +16 -0
- package/templates/ui/src/input.js +15 -0
- package/templates/ui/src/input.test.js +27 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/label.test.js +22 -0
- package/templates/ui/src/select.js +42 -0
- package/templates/ui/src/select.test.js +27 -0
- package/templates/ui/src/skeleton.js +14 -0
- package/templates/ui/src/skeleton.test.js +22 -0
- package/templates/ui/src/table.js +75 -0
- package/templates/ui/src/table.test.js +69 -0
- package/templates/ui/src/textarea.js +15 -0
- package/templates/ui/src/textarea.test.js +27 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +132 -0
- package/templates/ui/src/toast.js +229 -0
- package/templates/ui/src/toast.test.js +23 -0
- package/templates/{base-project/apps/worker → worker}/package.json +2 -2
- package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
- package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
- package/templates/base-project/apps/web/public/file.svg +0 -1
- package/templates/base-project/apps/web/public/globe.svg +0 -1
- package/templates/base-project/apps/web/public/next.svg +0 -1
- package/templates/base-project/apps/web/public/vercel.svg +0 -1
- package/templates/base-project/apps/web/public/window.svg +0 -1
- /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
- /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
|
@@ -1,49 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# __QUARK_PROJECT_NAME__
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffolded with [Quark](https://github.com/Bobnoddle/quark) on __QUARK_SCAFFOLD_DATE__.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Local Development
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
pnpm db:migrate
|
|
11
|
-
pnpm dev
|
|
8
|
+
docker compose up -d # Start PostgreSQL, Redis, Mailpit
|
|
9
|
+
pnpm install # Install dependencies
|
|
10
|
+
pnpm db:migrate # Apply migrations
|
|
11
|
+
pnpm dev # Start web + worker
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Open http://localhost:3000
|
|
14
|
+
Open [http://localhost:3000](http://localhost:3000)
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Database
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
| Task | Command |
|
|
19
|
+
|------|---------|
|
|
20
|
+
| Run migrations | `pnpm db:migrate` |
|
|
21
|
+
| Push schema (no migration) | `pnpm db:push` |
|
|
22
|
+
| Generate Prisma client | `pnpm db:generate` |
|
|
23
|
+
| Seed database | `pnpm db:seed` |
|
|
24
|
+
| Open Prisma Studio | `pnpm db:studio` |
|
|
22
25
|
|
|
23
|
-
##
|
|
26
|
+
## Other Commands
|
|
24
27
|
|
|
25
28
|
```bash
|
|
26
|
-
# Build all packages
|
|
27
|
-
pnpm
|
|
29
|
+
pnpm build # Build all packages
|
|
30
|
+
pnpm test # Run all tests (requires Docker)
|
|
31
|
+
pnpm lint # Lint + format check (Biome)
|
|
32
|
+
```
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
pnpm test
|
|
34
|
+
## Launch
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
### 1. Push to GitHub
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
gh repo create __QUARK_PROJECT_NAME__ --private --source=. --push
|
|
34
40
|
```
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
No `gh` CLI? Go to [github.com/new](https://github.com/new), create the repo, then:
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
```bash
|
|
45
|
+
git remote add origin https://github.com/<you>/__QUARK_PROJECT_NAME__.git
|
|
46
|
+
git push -u origin main
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Deploy on Railway
|
|
50
|
+
|
|
51
|
+
> **One-click Railway template coming soon.** For now, follow these steps:
|
|
52
|
+
|
|
53
|
+
1. Go to [railway.app](https://railway.app) → **New Project** → **Deploy from GitHub repo** → select `__QUARK_PROJECT_NAME__`
|
|
54
|
+
2. In the service → **Settings** → **Source** → set **Config File** to `apps/web/railway.json`
|
|
55
|
+
3. In the project → **+ New** → **Database** → **Add PostgreSQL** (injects `DATABASE_URL` automatically)
|
|
56
|
+
4. In the project → **+ New** → **Database** → **Add Redis** (injects `REDIS_URL` automatically)
|
|
57
|
+
5. In your web service → **Variables** → add:
|
|
58
|
+
- `NEXTAUTH_SECRET` → run `openssl rand -base64 32` locally and paste the result
|
|
59
|
+
- `APP_URL` → your Railway public domain (e.g. `https://__QUARK_PROJECT_NAME__.railway.app`)
|
|
60
|
+
6. *(If you included the worker)* **+ New** → **GitHub Repo** → same repo → **Config File** → `apps/worker/railway.json`
|
|
61
|
+
|
|
62
|
+
Railway auto-deploys on every push to `main`. Migrations run automatically before each deploy.
|
|
63
|
+
|
|
64
|
+
## AI-Assisted Development
|
|
65
|
+
|
|
66
|
+
This project ships with pre-loaded context for Claude Code, Cursor, GitHub Copilot, and others — see `CLAUDE.md` for the full reference.
|
|
67
|
+
|
|
68
|
+
**Suggested first prompt:**
|
|
69
|
+
|
|
70
|
+
> I'm building __QUARK_PROJECT_NAME__ — [describe what your app does in one sentence].
|
|
71
|
+
>
|
|
72
|
+
> Start by reviewing `CLAUDE.md` so you understand the full stack, then:
|
|
73
|
+
> 1. Add the Prisma models we need to `packages/db/prisma/schema.prisma`
|
|
74
|
+
> 2. Create the query helpers in `packages/db/src/queries.js`
|
|
75
|
+
> 3. Build the first page in `apps/web/src/app/` using our UI component system
|
|
76
|
+
|
|
77
|
+
Replace that bracketed description with what you're actually building, and the AI has everything it needs.
|
|
45
78
|
|
|
46
79
|
## Structure
|
|
47
80
|
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
```
|
|
82
|
+
__QUARK_PROJECT_NAME__/
|
|
83
|
+
├── apps/
|
|
84
|
+
│ ├── web/ # Next.js 16 (App Router, Server Actions)
|
|
85
|
+
│ └── worker/ # BullMQ background worker
|
|
86
|
+
├── packages/
|
|
87
|
+
│ ├── db/ # Prisma schema + query helpers
|
|
88
|
+
│ └── config/ # Environment validation
|
|
89
|
+
├── docker-compose.yml
|
|
90
|
+
└── CLAUDE.md # AI tool context — keep this updated
|
|
91
|
+
```
|
|
@@ -43,8 +43,12 @@ const nextConfig = {
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
key: "Content-Security-Policy",
|
|
46
|
+
// unsafe-eval is required by Turbopack in development only.
|
|
47
|
+
// It is deliberately excluded from the production directive.
|
|
46
48
|
value:
|
|
47
|
-
|
|
49
|
+
process.env.NODE_ENV !== "production"
|
|
50
|
+
? "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';"
|
|
51
|
+
: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';",
|
|
48
52
|
},
|
|
49
53
|
],
|
|
50
54
|
},
|
|
@@ -12,22 +12,24 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@auth/prisma-adapter": "^2.11.1",
|
|
15
|
+
"@aws-sdk/client-s3": "^3.1004.0",
|
|
16
|
+
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
|
15
17
|
"@techstream/quark-core": "^1.0.0",
|
|
16
18
|
"@techstream/quark-db": "workspace:*",
|
|
17
19
|
"@techstream/quark-jobs": "workspace:*",
|
|
18
20
|
"@techstream/quark-ui": "workspace:*",
|
|
19
|
-
"@prisma/client": "^7.4.
|
|
21
|
+
"@prisma/client": "^7.4.2",
|
|
20
22
|
"next": "16.1.6",
|
|
21
23
|
"next-auth": "5.0.0-beta.30",
|
|
22
|
-
"pg": "^8.
|
|
24
|
+
"pg": "^8.20.0",
|
|
23
25
|
"react": "19.2.4",
|
|
24
26
|
"react-dom": "19.2.4",
|
|
25
27
|
"zod": "^4.3.6"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
|
-
"@tailwindcss/postcss": "^4.2.
|
|
29
|
-
"@types/node": "^25.
|
|
30
|
-
"tailwindcss": "^4.2.
|
|
30
|
+
"@tailwindcss/postcss": "^4.2.1",
|
|
31
|
+
"@types/node": "^25.3.5",
|
|
32
|
+
"tailwindcss": "^4.2.1",
|
|
31
33
|
"@techstream/quark-config": "workspace:*"
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
|
2
|
+
<!--
|
|
3
|
+
Quark logo — SVG master
|
|
4
|
+
Geometry mirrors QuarkAnimation.js constants (ring/dash thickness ratio ~0.20)
|
|
5
|
+
|
|
6
|
+
Ring: center (100,100), centerline radius 65, stroke-width 26
|
|
7
|
+
Gap: centred at 45.8° (dashAngle=0.8 rad), half-width 27.1° (0.472 rad)
|
|
8
|
+
Dash: radial at 45.8°, from radius 23.4 → 103.4 (proportional to animation dL)
|
|
9
|
+
stroke-width 28 (= dashThickness/ringThickness × 26 = 2.8/2.6 × 26)
|
|
10
|
+
|
|
11
|
+
Colors: #2d3436 (dark/top) · #377dff (blue/bottom-left) · #ff4757 (red/tail)
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
<!-- Dark arc — upper semicircle: left (180°) → top (270°) → right (360°) -->
|
|
15
|
+
<path
|
|
16
|
+
d="M 35,100 A 65,65 0 0,1 165,100"
|
|
17
|
+
stroke="#2d3436"
|
|
18
|
+
stroke-width="26"
|
|
19
|
+
stroke-linecap="butt"
|
|
20
|
+
/>
|
|
21
|
+
|
|
22
|
+
<!-- Blue arc — bottom-left: gap-end (72.9°) → left (180°) -->
|
|
23
|
+
<path
|
|
24
|
+
d="M 119.1,162.1 A 65,65 0 0,1 35,100"
|
|
25
|
+
stroke="#377dff"
|
|
26
|
+
stroke-width="26"
|
|
27
|
+
stroke-linecap="butt"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<!-- Red arc — tiny pre-gap sliver: right (0°) → gap-start (18.8°) -->
|
|
31
|
+
<path
|
|
32
|
+
d="M 165,100 A 65,65 0 0,1 161.5,121"
|
|
33
|
+
stroke="#ff4757"
|
|
34
|
+
stroke-width="26"
|
|
35
|
+
stroke-linecap="butt"
|
|
36
|
+
/>
|
|
37
|
+
|
|
38
|
+
<!-- Red dash — radial tail through the gap -->
|
|
39
|
+
<line
|
|
40
|
+
x1="116.3" y1="116.8"
|
|
41
|
+
x2="172" y2="174.2"
|
|
42
|
+
stroke="#ff4757"
|
|
43
|
+
stroke-width="28"
|
|
44
|
+
stroke-linecap="butt"
|
|
45
|
+
/>
|
|
46
|
+
</svg>
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
"watchPatterns": ["apps/web/**", "packages/**"]
|
|
7
7
|
},
|
|
8
8
|
"deploy": {
|
|
9
|
-
"releaseCommand": "pnpm --filter
|
|
9
|
+
"releaseCommand": "pnpm --filter './packages/db' exec prisma migrate deploy",
|
|
10
10
|
"startCommand": "node apps/web/.next/standalone/server.js",
|
|
11
11
|
"healthcheckPath": "/api/health",
|
|
12
|
-
"healthcheckTimeout":
|
|
12
|
+
"healthcheckTimeout": 120,
|
|
13
13
|
"restartPolicyType": "ON_FAILURE",
|
|
14
14
|
"restartPolicyMaxRetries": 5
|
|
15
15
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
const STATUS_COLORS = {
|
|
6
|
+
ok: "#22c55e",
|
|
7
|
+
degraded: "#f59e0b",
|
|
8
|
+
error: "#ef4444",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function Dot({ status, label }) {
|
|
12
|
+
const dotColor = STATUS_COLORS[status] ?? "var(--quark-health-dot-idle)";
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
title={`${label}: ${status ?? "checking…"}`}
|
|
16
|
+
style={{
|
|
17
|
+
display: "inline-flex",
|
|
18
|
+
alignItems: "center",
|
|
19
|
+
gap: "4px",
|
|
20
|
+
color: "var(--quark-health-text)",
|
|
21
|
+
fontFamily: "monospace",
|
|
22
|
+
fontSize: "11px",
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
style={{
|
|
27
|
+
display: "inline-block",
|
|
28
|
+
width: "5px",
|
|
29
|
+
height: "5px",
|
|
30
|
+
borderRadius: "50%",
|
|
31
|
+
backgroundColor: dotColor,
|
|
32
|
+
transition: "background-color 0.3s ease",
|
|
33
|
+
flexShrink: 0,
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
{label}
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetches /api/health once on mount and renders small coloured dots for
|
|
43
|
+
* database, Redis, and storage status. Shows neutral dots while loading.
|
|
44
|
+
* Fails silently so the home page aesthetic is never disrupted.
|
|
45
|
+
* Colours are driven by CSS custom properties (prefers-color-scheme aware).
|
|
46
|
+
*/
|
|
47
|
+
export default function HealthIndicator() {
|
|
48
|
+
const [checks, setChecks] = useState(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
fetch("/api/health")
|
|
53
|
+
.then((r) => r.json())
|
|
54
|
+
.then((data) => {
|
|
55
|
+
if (!cancelled) setChecks(data.checks ?? {});
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
if (!cancelled) setChecks({});
|
|
59
|
+
});
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
display: "flex",
|
|
69
|
+
alignItems: "center",
|
|
70
|
+
gap: "8px",
|
|
71
|
+
fontFamily: "monospace",
|
|
72
|
+
fontSize: "11px",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Dot status={checks?.database?.status} label="db" />
|
|
76
|
+
<span style={{ color: "var(--quark-health-sep)" }}>·</span>
|
|
77
|
+
<Dot status={checks?.redis?.status} label="redis" />
|
|
78
|
+
<span style={{ color: "var(--quark-health-sep)" }}>·</span>
|
|
79
|
+
<Dot
|
|
80
|
+
status={checks?.storage?.status}
|
|
81
|
+
label={checks?.storage?.provider ?? "storage"}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
THEME_ATTR,
|
|
6
|
+
THEME_CHANGE_EVENT,
|
|
7
|
+
THEME_STORAGE_KEY,
|
|
8
|
+
} from "../../lib/theme.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Text-link theme toggle for the home page nav.
|
|
12
|
+
* Shows "light" when dark mode is active (click to switch to light) and vice versa.
|
|
13
|
+
* Styled identically to the other nav links via the quark-home-link class.
|
|
14
|
+
*
|
|
15
|
+
* Uses the same localStorage key as ThemeProvider (THEME_STORAGE_KEY) and sets
|
|
16
|
+
* the same HTML attribute (THEME_ATTR) so CSS variables react instantly. Fires
|
|
17
|
+
* a THEME_CHANGE_EVENT custom event so ThemeProvider (if present) stays in sync
|
|
18
|
+
* without any import coupling between the two packages.
|
|
19
|
+
*/
|
|
20
|
+
export default function HomeThemeToggle() {
|
|
21
|
+
const [isDark, setIsDark] = useState(true);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
25
|
+
if (stored === "light" || stored === "dark") {
|
|
26
|
+
setIsDark(stored === "dark");
|
|
27
|
+
document.documentElement.setAttribute(THEME_ATTR, stored);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const osDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
31
|
+
setIsDark(osDark);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
function toggle() {
|
|
35
|
+
setIsDark((prev) => {
|
|
36
|
+
const next = prev ? "light" : "dark";
|
|
37
|
+
localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
38
|
+
document.documentElement.setAttribute(THEME_ATTR, next);
|
|
39
|
+
// Notify ThemeProvider (if present in the tree) so React context stays in sync.
|
|
40
|
+
document.dispatchEvent(
|
|
41
|
+
new CustomEvent(THEME_CHANGE_EVENT, { detail: { theme: next } }),
|
|
42
|
+
);
|
|
43
|
+
return !prev;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={toggle}
|
|
51
|
+
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
|
|
52
|
+
className="quark-home-link"
|
|
53
|
+
style={{
|
|
54
|
+
background: "none",
|
|
55
|
+
border: "none",
|
|
56
|
+
padding: 0,
|
|
57
|
+
cursor: "pointer",
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{isDark ? "light" : "dark"}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Times out after 5 seconds to prevent hanging.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createLogger, pingRedis } from "@techstream/quark-core";
|
|
8
|
-
import {
|
|
7
|
+
import { createLogger, createStorage, pingRedis } from "@techstream/quark-core";
|
|
8
|
+
import { pingDatabase } from "@techstream/quark-db";
|
|
9
9
|
import { NextResponse } from "next/server";
|
|
10
10
|
|
|
11
11
|
const logger = createLogger("health");
|
|
@@ -15,18 +15,25 @@ const HEALTH_CHECK_TIMEOUT = 5000;
|
|
|
15
15
|
|
|
16
16
|
export async function GET() {
|
|
17
17
|
try {
|
|
18
|
-
const result = await Promise
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
const result = await new Promise((resolve, reject) => {
|
|
19
|
+
const timer = setTimeout(
|
|
20
|
+
() => reject(new Error("Health check timed out")),
|
|
21
|
+
HEALTH_CHECK_TIMEOUT,
|
|
22
|
+
);
|
|
23
|
+
runHealthChecks().then(
|
|
24
|
+
(val) => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
resolve(val);
|
|
27
|
+
},
|
|
28
|
+
(err) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(err);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
27
34
|
|
|
28
35
|
return NextResponse.json(result, {
|
|
29
|
-
status: result.status === "
|
|
36
|
+
status: result.status === "error" ? 503 : 200,
|
|
30
37
|
});
|
|
31
38
|
} catch (error) {
|
|
32
39
|
logger.error("Health check failed", {
|
|
@@ -56,13 +63,13 @@ async function runHealthChecks() {
|
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
// Check database connectivity
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
health.checks.database = { status: "ok" };
|
|
62
|
-
}
|
|
66
|
+
const dbResult = await pingDatabase();
|
|
67
|
+
if (dbResult.status === "ok") {
|
|
68
|
+
health.checks.database = { status: "ok", latencyMs: dbResult.latencyMs };
|
|
69
|
+
} else {
|
|
63
70
|
health.checks.database = {
|
|
64
71
|
status: "error",
|
|
65
|
-
message:
|
|
72
|
+
message: dbResult.message,
|
|
66
73
|
};
|
|
67
74
|
health.status = "degraded";
|
|
68
75
|
}
|
|
@@ -79,5 +86,37 @@ async function runHealthChecks() {
|
|
|
79
86
|
health.status = "degraded";
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
// Check storage connectivity
|
|
90
|
+
const storageResult = await checkStorage();
|
|
91
|
+
health.checks.storage = storageResult;
|
|
92
|
+
if (storageResult.status === "error") {
|
|
93
|
+
health.status = "degraded";
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
return health;
|
|
83
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verifies storage is reachable and writable by writing then deleting a
|
|
101
|
+
* small sentinel object. Uses whichever provider is configured via
|
|
102
|
+
* STORAGE_PROVIDER (defaults to "local" when unset).
|
|
103
|
+
* @returns {Promise<{ status: string, provider: string, message?: string }>}
|
|
104
|
+
*/
|
|
105
|
+
async function checkStorage() {
|
|
106
|
+
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
107
|
+
try {
|
|
108
|
+
const storage = createStorage();
|
|
109
|
+
const sentinelKey = ".health-check-sentinel";
|
|
110
|
+
await storage.put(sentinelKey, Buffer.from("ok"), {
|
|
111
|
+
contentType: "text/plain",
|
|
112
|
+
});
|
|
113
|
+
await storage.delete(sentinelKey);
|
|
114
|
+
return { status: "ok", provider };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message =
|
|
117
|
+
process.env.NODE_ENV === "production"
|
|
118
|
+
? "Storage unavailable"
|
|
119
|
+
: error.message;
|
|
120
|
+
return { status: "error", provider, message };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
Binary file
|