@techstream/quark-create-app 1.9.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 +1 -1
- package/src/index.js +376 -143
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.github/copilot-instructions.md +55 -0
- 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 +3 -3
- 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 +28 -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 +2 -0
- 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/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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const THEMES = {
|
|
4
|
+
light: {
|
|
5
|
+
select:
|
|
6
|
+
"block h-10 w-full appearance-none rounded-sm border border-gray-300 bg-white px-3 pr-9 text-sm text-gray-900 shadow-sm transition-all duration-200 cursor-pointer hover:border-gray-400 focus-visible:border-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed",
|
|
7
|
+
chevron:
|
|
8
|
+
"absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none",
|
|
9
|
+
},
|
|
10
|
+
dark: {
|
|
11
|
+
select:
|
|
12
|
+
"block h-10 w-full appearance-none rounded-sm border border-[#1e2535] bg-[#090d14] px-3 pr-9 text-sm text-[#e0e0e0] font-mono transition-all duration-200 cursor-pointer hover:border-[#377dff]/30 focus-visible:border-[#377dff]/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#377dff]/15 disabled:opacity-30 disabled:cursor-not-allowed",
|
|
13
|
+
chevron:
|
|
14
|
+
"absolute right-3 top-1/2 -translate-y-1/2 text-[#4a4a6a] pointer-events-none",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function Select({
|
|
19
|
+
theme = "light",
|
|
20
|
+
className = "",
|
|
21
|
+
children,
|
|
22
|
+
...props
|
|
23
|
+
}) {
|
|
24
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
25
|
+
return React.createElement(
|
|
26
|
+
"div",
|
|
27
|
+
{ className: "relative" },
|
|
28
|
+
React.createElement(
|
|
29
|
+
"select",
|
|
30
|
+
{ className: `${t.select} ${className}`.trim(), ...props },
|
|
31
|
+
children,
|
|
32
|
+
),
|
|
33
|
+
React.createElement(
|
|
34
|
+
"span",
|
|
35
|
+
{
|
|
36
|
+
"aria-hidden": "true",
|
|
37
|
+
className: t.chevron,
|
|
38
|
+
},
|
|
39
|
+
"\u25be",
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { Select } from "./select.js";
|
|
4
|
+
|
|
5
|
+
test("Select - exports correctly", () => {
|
|
6
|
+
assert(typeof Select === "function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("Select - renders with default props", () => {
|
|
10
|
+
const result = Select({});
|
|
11
|
+
assert.ok(result);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Select - accepts className override", () => {
|
|
15
|
+
const result = Select({ className: "w-48" });
|
|
16
|
+
assert.ok(result);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Select - accepts children", () => {
|
|
20
|
+
const result = Select({ children: null });
|
|
21
|
+
assert.ok(result);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Select - accepts disabled prop", () => {
|
|
25
|
+
const result = Select({ disabled: true });
|
|
26
|
+
assert.ok(result);
|
|
27
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const THEMES = {
|
|
4
|
+
light: "animate-pulse rounded-lg bg-gray-200/80",
|
|
5
|
+
dark: "animate-pulse rounded-lg bg-[#1e2535]/70",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function Skeleton({ theme = "light", className = "", ...props }) {
|
|
9
|
+
const base = THEMES[theme] ?? THEMES.light;
|
|
10
|
+
return React.createElement("div", {
|
|
11
|
+
className: `${base} ${className}`.trim(),
|
|
12
|
+
...props,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { Skeleton } from "./skeleton.js";
|
|
4
|
+
|
|
5
|
+
test("Skeleton - exports correctly", () => {
|
|
6
|
+
assert(typeof Skeleton === "function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("Skeleton - renders with default props", () => {
|
|
10
|
+
const result = Skeleton({});
|
|
11
|
+
assert.ok(result);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Skeleton - accepts className for dimensions", () => {
|
|
15
|
+
const result = Skeleton({ className: "h-4 w-32" });
|
|
16
|
+
assert.ok(result);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Skeleton - accepts aria props", () => {
|
|
20
|
+
const result = Skeleton({ "aria-label": "Loading..." });
|
|
21
|
+
assert.ok(result);
|
|
22
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const THEMES = {
|
|
4
|
+
light: {
|
|
5
|
+
wrapper: "w-full overflow-auto rounded-xl border border-gray-200 bg-white",
|
|
6
|
+
table: "w-full caption-bottom text-sm",
|
|
7
|
+
header: "border-b bg-gray-50",
|
|
8
|
+
body: "[&_tr:last-child]:border-0",
|
|
9
|
+
row: "border-b border-gray-100 transition-colors hover:bg-gray-50/80 active:bg-gray-100",
|
|
10
|
+
head: "h-11 px-3 text-left align-middle font-medium text-gray-600",
|
|
11
|
+
cell: "p-3 align-middle text-gray-700",
|
|
12
|
+
},
|
|
13
|
+
dark: {
|
|
14
|
+
wrapper:
|
|
15
|
+
"w-full overflow-auto rounded-xl border border-[#1e2535] bg-[#0d1117]",
|
|
16
|
+
table: "w-full caption-bottom text-sm",
|
|
17
|
+
header: "border-b border-[#1e2535] bg-[#090d14]",
|
|
18
|
+
body: "[&_tr:last-child]:border-0",
|
|
19
|
+
row: "border-b border-[#1e2535]/50 transition-colors hover:bg-[#1e2535]/40",
|
|
20
|
+
head: "h-11 px-3 text-left align-middle font-medium text-[#4a4a6a] font-mono text-xs uppercase tracking-widest",
|
|
21
|
+
cell: "p-3 align-middle text-[#e0e0e0] font-mono text-sm",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function Table({ theme = "light", className = "", ...props }) {
|
|
26
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
27
|
+
return React.createElement(
|
|
28
|
+
"div",
|
|
29
|
+
{ className: t.wrapper },
|
|
30
|
+
React.createElement("table", {
|
|
31
|
+
className: `${t.table} ${className}`.trim(),
|
|
32
|
+
...props,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function TableHeader({ theme = "light", className = "", ...props }) {
|
|
38
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
39
|
+
return React.createElement("thead", {
|
|
40
|
+
className: `${t.header} ${className}`.trim(),
|
|
41
|
+
...props,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function TableBody({ theme = "light", className = "", ...props }) {
|
|
46
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
47
|
+
return React.createElement("tbody", {
|
|
48
|
+
className: `${t.body} ${className}`.trim(),
|
|
49
|
+
...props,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TableRow({ theme = "light", className = "", ...props }) {
|
|
54
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
55
|
+
return React.createElement("tr", {
|
|
56
|
+
className: `${t.row} ${className}`.trim(),
|
|
57
|
+
...props,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function TableHead({ theme = "light", className = "", ...props }) {
|
|
62
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
63
|
+
return React.createElement("th", {
|
|
64
|
+
className: `${t.head} ${className}`.trim(),
|
|
65
|
+
...props,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TableCell({ theme = "light", className = "", ...props }) {
|
|
70
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
71
|
+
return React.createElement("td", {
|
|
72
|
+
className: `${t.cell} ${className}`.trim(),
|
|
73
|
+
...props,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from "./table.js";
|
|
11
|
+
|
|
12
|
+
test("Table - exports correctly", () => {
|
|
13
|
+
assert(typeof Table === "function");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("TableHeader - exports correctly", () => {
|
|
17
|
+
assert(typeof TableHeader === "function");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("TableBody - exports correctly", () => {
|
|
21
|
+
assert(typeof TableBody === "function");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("TableRow - exports correctly", () => {
|
|
25
|
+
assert(typeof TableRow === "function");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("TableHead - exports correctly", () => {
|
|
29
|
+
assert(typeof TableHead === "function");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("TableCell - exports correctly", () => {
|
|
33
|
+
assert(typeof TableCell === "function");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Table - renders with default props", () => {
|
|
37
|
+
const result = Table({});
|
|
38
|
+
assert.ok(result);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("TableHeader - renders with default props", () => {
|
|
42
|
+
const result = TableHeader({});
|
|
43
|
+
assert.ok(result);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("TableBody - renders with default props", () => {
|
|
47
|
+
const result = TableBody({});
|
|
48
|
+
assert.ok(result);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("TableRow - renders with default props", () => {
|
|
52
|
+
const result = TableRow({});
|
|
53
|
+
assert.ok(result);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("TableHead - renders with default props", () => {
|
|
57
|
+
const result = TableHead({});
|
|
58
|
+
assert.ok(result);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("TableCell - renders with default props", () => {
|
|
62
|
+
const result = TableCell({});
|
|
63
|
+
assert.ok(result);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Table - accepts className override", () => {
|
|
67
|
+
const result = Table({ className: "custom" });
|
|
68
|
+
assert.ok(result);
|
|
69
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const THEMES = {
|
|
4
|
+
light:
|
|
5
|
+
"block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-400 focus-visible:border-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed resize-y",
|
|
6
|
+
dark: "block w-full rounded-sm border border-[#1e2535] bg-[#090d14] px-3 py-2 text-sm text-[#e0e0e0] placeholder-[#2d3a52] font-mono transition-all duration-200 hover:border-[#377dff]/30 focus-visible:border-[#377dff]/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#377dff]/15 disabled:opacity-30 disabled:cursor-not-allowed resize-y",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Textarea({ theme = "light", className = "", ...props }) {
|
|
10
|
+
const base = THEMES[theme] ?? THEMES.light;
|
|
11
|
+
return React.createElement("textarea", {
|
|
12
|
+
className: `${base} ${className}`.trim(),
|
|
13
|
+
...props,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { Textarea } from "./textarea.js";
|
|
4
|
+
|
|
5
|
+
test("Textarea - exports correctly", () => {
|
|
6
|
+
assert(typeof Textarea === "function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("Textarea - renders with default props", () => {
|
|
10
|
+
const result = Textarea({});
|
|
11
|
+
assert.ok(result);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Textarea - accepts className override", () => {
|
|
15
|
+
const result = Textarea({ className: "h-32" });
|
|
16
|
+
assert.ok(result);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Textarea - accepts rows prop", () => {
|
|
20
|
+
const result = Textarea({ rows: 4, placeholder: "Enter text..." });
|
|
21
|
+
assert.ok(result);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Textarea - accepts disabled prop", () => {
|
|
25
|
+
const result = Textarea({ disabled: true });
|
|
26
|
+
assert.ok(result);
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme system constants for @techstream/quark-ui.
|
|
3
|
+
*
|
|
4
|
+
* These three values form the contract between ThemeProvider and any
|
|
5
|
+
* out-of-tree component (e.g. HomeThemeToggle in apps/web) that interoperates
|
|
6
|
+
* with it via the shared localStorage key, HTML attribute, and DOM event.
|
|
7
|
+
*
|
|
8
|
+
* An identical copy lives in apps/web/src/lib/theme.js — each package owns
|
|
9
|
+
* its own copy so there is no cross-package import. If you rename any of
|
|
10
|
+
* these, update both files and the CSS selectors in globals.css.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Key used to persist the user's explicit theme choice in localStorage. */
|
|
14
|
+
export const THEME_STORAGE_KEY = "quark-theme";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Attribute set on <html> so CSS custom properties can react to JS state.
|
|
18
|
+
* Use via element.setAttribute(THEME_ATTR, value) / element.getAttribute(THEME_ATTR).
|
|
19
|
+
* Referenced in globals.css as [data-theme="light"] / [data-theme="dark"].
|
|
20
|
+
*/
|
|
21
|
+
export const THEME_ATTR = "data-theme";
|
|
22
|
+
|
|
23
|
+
/** CustomEvent name dispatched when the theme changes outside a ThemeProvider. */
|
|
24
|
+
export const THEME_CHANGE_EVENT = "quark-theme-change";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
THEME_ATTR,
|
|
5
|
+
THEME_CHANGE_EVENT,
|
|
6
|
+
THEME_STORAGE_KEY,
|
|
7
|
+
} from "./theme-constants.js";
|
|
8
|
+
|
|
9
|
+
const ThemeCtx = createContext({ theme: "dark", setTheme: () => {} });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a subtree with a shared theme value.
|
|
13
|
+
*
|
|
14
|
+
* Behaviour:
|
|
15
|
+
* - If `defaultTheme` is provided it is used as the initial value (good for
|
|
16
|
+
* pages that have a deliberate starting theme, e.g. the playground).
|
|
17
|
+
* - If `defaultTheme` is omitted, the initial value is derived from the OS
|
|
18
|
+
* `prefers-color-scheme` media query on first mount.
|
|
19
|
+
* - In both cases the user's explicit toggle choice is persisted to
|
|
20
|
+
* `localStorage` under the key `quark-theme` and restored on subsequent
|
|
21
|
+
* visits so their preference is remembered across sessions.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ defaultTheme?: 'light' | 'dark', children: React.ReactNode }} props
|
|
24
|
+
*/
|
|
25
|
+
export function ThemeProvider({ defaultTheme, children }) {
|
|
26
|
+
// Lazy initialiser — runs synchronously on the client before first paint,
|
|
27
|
+
// so the correct theme is in place from frame 0 (no flash).
|
|
28
|
+
// On the server `window` is undefined, so we fall back to defaultTheme ?? 'dark'.
|
|
29
|
+
const [theme, setTheme] = useState(() => {
|
|
30
|
+
if (typeof window === "undefined") return defaultTheme ?? "dark";
|
|
31
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
32
|
+
if (stored === "light" || stored === "dark") return stored;
|
|
33
|
+
if (defaultTheme != null) return defaultTheme;
|
|
34
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
35
|
+
? "dark"
|
|
36
|
+
: "light";
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Keep state in sync when the OS colour scheme changes (only relevant when
|
|
40
|
+
// no defaultTheme was provided and the user has no stored preference).
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
43
|
+
if (stored || defaultTheme != null) return;
|
|
44
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
45
|
+
const onChange = (e) => setTheme(e.matches ? "dark" : "light");
|
|
46
|
+
mq.addEventListener("change", onChange);
|
|
47
|
+
return () => mq.removeEventListener("change", onChange);
|
|
48
|
+
}, [defaultTheme]);
|
|
49
|
+
|
|
50
|
+
// Listen for toggles fired by HomeThemeToggle (or any other out-of-tree
|
|
51
|
+
// component) so React context stays in sync without any import coupling.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
function onExternalChange(e) {
|
|
54
|
+
const t = e.detail?.theme;
|
|
55
|
+
if (t === "light" || t === "dark") setTheme(t);
|
|
56
|
+
}
|
|
57
|
+
document.addEventListener(THEME_CHANGE_EVENT, onExternalChange);
|
|
58
|
+
return () =>
|
|
59
|
+
document.removeEventListener(THEME_CHANGE_EVENT, onExternalChange);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
function persistSetTheme(t) {
|
|
63
|
+
localStorage.setItem(THEME_STORAGE_KEY, t);
|
|
64
|
+
document.documentElement.setAttribute(THEME_ATTR, t);
|
|
65
|
+
setTheme(t);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return React.createElement(
|
|
69
|
+
ThemeCtx.Provider,
|
|
70
|
+
{ value: { theme, setTheme: persistSetTheme } },
|
|
71
|
+
children,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the current theme and a setter from the nearest ThemeProvider.
|
|
77
|
+
* Falls back to `{ theme: 'light' }` when used outside a provider.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
|
|
80
|
+
*/
|
|
81
|
+
export function useTheme() {
|
|
82
|
+
return useContext(ThemeCtx);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Slide-pill toggle that switches between light and dark themes.
|
|
87
|
+
* 32×18 px track with a 12 px sliding knob. Pure inline styles — no
|
|
88
|
+
* Tailwind dependency. Must be rendered inside a ThemeProvider.
|
|
89
|
+
*/
|
|
90
|
+
export function ThemeToggle({ className = "", style = {} }) {
|
|
91
|
+
const { theme, setTheme } = useTheme();
|
|
92
|
+
const isDark = theme === "dark";
|
|
93
|
+
|
|
94
|
+
return React.createElement(
|
|
95
|
+
"button",
|
|
96
|
+
{
|
|
97
|
+
type: "button",
|
|
98
|
+
onClick: () => setTheme(isDark ? "light" : "dark"),
|
|
99
|
+
"aria-label": `Switch to ${isDark ? "light" : "dark"} theme`,
|
|
100
|
+
"aria-pressed": isDark,
|
|
101
|
+
className: className || undefined,
|
|
102
|
+
style: {
|
|
103
|
+
display: "inline-flex",
|
|
104
|
+
alignItems: "center",
|
|
105
|
+
width: "32px",
|
|
106
|
+
height: "18px",
|
|
107
|
+
borderRadius: "9px",
|
|
108
|
+
border: `1px solid ${isDark ? "#1e2d45" : "#d1d5db"}`,
|
|
109
|
+
background: isDark ? "#0d1420" : "#e5e7eb",
|
|
110
|
+
cursor: "pointer",
|
|
111
|
+
padding: "2px",
|
|
112
|
+
transition: "background 0.2s ease, border-color 0.2s ease",
|
|
113
|
+
outline: "none",
|
|
114
|
+
flexShrink: 0,
|
|
115
|
+
...style,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
React.createElement("span", {
|
|
119
|
+
"aria-hidden": "true",
|
|
120
|
+
style: {
|
|
121
|
+
display: "block",
|
|
122
|
+
width: "12px",
|
|
123
|
+
height: "12px",
|
|
124
|
+
borderRadius: "50%",
|
|
125
|
+
background: isDark ? "#377dff" : "#9ca3af",
|
|
126
|
+
transform: isDark ? "translateX(14px)" : "translateX(0)",
|
|
127
|
+
transition: "transform 0.2s ease, background 0.2s ease",
|
|
128
|
+
flexShrink: 0,
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Toast — fixed bottom-right notification with auto-dismiss countdown.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* message (string) — notification text
|
|
9
|
+
* variant ('default'|'success'|'error') — colour scheme
|
|
10
|
+
* onClose (fn) — called when dismissed / expired
|
|
11
|
+
* visible (bool) — controlled visibility
|
|
12
|
+
* duration (number) — ms before auto-dismiss (default 4000)
|
|
13
|
+
* theme (string) — 'light' (default) | 'dark'
|
|
14
|
+
* toastId (number) — incremented by useToast on each show()
|
|
15
|
+
* so the timer resets correctly
|
|
16
|
+
*
|
|
17
|
+
* Features:
|
|
18
|
+
* • SVG circle progress shows remaining time
|
|
19
|
+
* • Hovering pauses the timer and replaces the circle with a × close button
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const RADIUS = 9;
|
|
23
|
+
const CIRC = 2 * Math.PI * RADIUS; // ≈ 56.55
|
|
24
|
+
|
|
25
|
+
const THEMES = {
|
|
26
|
+
light: {
|
|
27
|
+
default: {
|
|
28
|
+
cls: "border border-gray-700 bg-gray-900 text-white",
|
|
29
|
+
track: "rgba(255,255,255,0.2)",
|
|
30
|
+
progress: "rgba(255,255,255,0.85)",
|
|
31
|
+
},
|
|
32
|
+
success: {
|
|
33
|
+
cls: "border border-green-500 bg-green-600 text-white",
|
|
34
|
+
track: "rgba(255,255,255,0.2)",
|
|
35
|
+
progress: "rgba(255,255,255,0.85)",
|
|
36
|
+
},
|
|
37
|
+
error: {
|
|
38
|
+
cls: "border border-red-500 bg-red-600 text-white",
|
|
39
|
+
track: "rgba(255,255,255,0.2)",
|
|
40
|
+
progress: "rgba(255,255,255,0.85)",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
dark: {
|
|
44
|
+
default: {
|
|
45
|
+
cls: "border border-[#1e2535] bg-[#090d14] text-[#e0e0e0]",
|
|
46
|
+
track: "rgba(55,125,255,0.12)",
|
|
47
|
+
progress: "rgba(55,125,255,0.7)",
|
|
48
|
+
},
|
|
49
|
+
success: {
|
|
50
|
+
cls: "border border-emerald-800/50 bg-[#090d14] text-emerald-400",
|
|
51
|
+
track: "rgba(52,211,153,0.12)",
|
|
52
|
+
progress: "rgba(52,211,153,0.7)",
|
|
53
|
+
},
|
|
54
|
+
error: {
|
|
55
|
+
cls: "border border-[#ff4757]/40 bg-[#090d14] text-[#ff4757]",
|
|
56
|
+
track: "rgba(255,71,87,0.12)",
|
|
57
|
+
progress: "rgba(255,71,87,0.7)",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function Toast({
|
|
63
|
+
message,
|
|
64
|
+
variant = "default",
|
|
65
|
+
onClose,
|
|
66
|
+
visible = true,
|
|
67
|
+
duration = 4000,
|
|
68
|
+
theme = "light",
|
|
69
|
+
_toastId,
|
|
70
|
+
}) {
|
|
71
|
+
const [progress, setProgress] = useState(100); // 100 → 0 as time elapses
|
|
72
|
+
const [hovered, setHovered] = useState(false);
|
|
73
|
+
|
|
74
|
+
const themeMap = THEMES[theme] ?? THEMES.light;
|
|
75
|
+
const v = themeMap[variant] ?? themeMap.default;
|
|
76
|
+
|
|
77
|
+
// Mutable refs so the interval callback always reads fresh values
|
|
78
|
+
const remainingRef = useRef(duration);
|
|
79
|
+
const startRef = useRef(null);
|
|
80
|
+
const intervalRef = useRef(null);
|
|
81
|
+
|
|
82
|
+
const stop = useCallback(() => clearInterval(intervalRef.current), []);
|
|
83
|
+
|
|
84
|
+
const start = useCallback(() => {
|
|
85
|
+
stop();
|
|
86
|
+
startRef.current = Date.now();
|
|
87
|
+
intervalRef.current = setInterval(() => {
|
|
88
|
+
const elapsed = Date.now() - startRef.current;
|
|
89
|
+
const pct = Math.max(0, 100 - (elapsed / remainingRef.current) * 100);
|
|
90
|
+
setProgress(pct);
|
|
91
|
+
if (pct <= 0) {
|
|
92
|
+
stop();
|
|
93
|
+
onClose?.();
|
|
94
|
+
}
|
|
95
|
+
}, 16);
|
|
96
|
+
}, [stop, onClose]);
|
|
97
|
+
|
|
98
|
+
// Reset when toast becomes visible or a new toast is shown (toastId changes)
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!visible) {
|
|
101
|
+
stop();
|
|
102
|
+
setProgress(100);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
remainingRef.current = duration;
|
|
106
|
+
setProgress(100);
|
|
107
|
+
setHovered(false);
|
|
108
|
+
start();
|
|
109
|
+
return stop;
|
|
110
|
+
}, [visible, duration, start, stop]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
111
|
+
|
|
112
|
+
// Pause on hover, RESET on leave (restart from full duration)
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!visible) return;
|
|
115
|
+
if (hovered) {
|
|
116
|
+
stop();
|
|
117
|
+
} else {
|
|
118
|
+
// Reset to full duration on every mouse-leave
|
|
119
|
+
remainingRef.current = duration;
|
|
120
|
+
start();
|
|
121
|
+
}
|
|
122
|
+
}, [hovered, duration, start, stop, visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
|
+
|
|
124
|
+
if (!visible) return null;
|
|
125
|
+
|
|
126
|
+
const dashOffset = CIRC * (1 - progress / 100);
|
|
127
|
+
|
|
128
|
+
return React.createElement(
|
|
129
|
+
"div",
|
|
130
|
+
{
|
|
131
|
+
role: "alert",
|
|
132
|
+
"aria-live": "assertive",
|
|
133
|
+
onMouseEnter: () => setHovered(true),
|
|
134
|
+
onMouseLeave: () => setHovered(false),
|
|
135
|
+
className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded px-4 py-3 text-sm shadow-xl transition-all duration-200 ${v.cls}`,
|
|
136
|
+
},
|
|
137
|
+
React.createElement("span", null, message),
|
|
138
|
+
// Trailing indicator: × when hovered, progress circle otherwise
|
|
139
|
+
React.createElement(
|
|
140
|
+
"div",
|
|
141
|
+
{
|
|
142
|
+
className:
|
|
143
|
+
"relative ml-2 flex h-6 w-6 shrink-0 items-center justify-center",
|
|
144
|
+
},
|
|
145
|
+
hovered
|
|
146
|
+
? React.createElement(
|
|
147
|
+
"button",
|
|
148
|
+
{
|
|
149
|
+
type: "button",
|
|
150
|
+
"aria-label": "Dismiss notification",
|
|
151
|
+
onClick: onClose,
|
|
152
|
+
className:
|
|
153
|
+
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-sm text-base leading-none opacity-80 transition-all hover:bg-white/15 hover:opacity-100 active:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60",
|
|
154
|
+
},
|
|
155
|
+
"\u00d7",
|
|
156
|
+
)
|
|
157
|
+
: React.createElement(
|
|
158
|
+
"svg",
|
|
159
|
+
{
|
|
160
|
+
width: 24,
|
|
161
|
+
height: 24,
|
|
162
|
+
viewBox: "0 0 24 24",
|
|
163
|
+
"aria-hidden": "true",
|
|
164
|
+
},
|
|
165
|
+
// Track ring
|
|
166
|
+
React.createElement("circle", {
|
|
167
|
+
cx: 12,
|
|
168
|
+
cy: 12,
|
|
169
|
+
r: RADIUS,
|
|
170
|
+
fill: "none",
|
|
171
|
+
stroke: v.track,
|
|
172
|
+
strokeWidth: 2,
|
|
173
|
+
}),
|
|
174
|
+
// Progress arc
|
|
175
|
+
React.createElement("circle", {
|
|
176
|
+
cx: 12,
|
|
177
|
+
cy: 12,
|
|
178
|
+
r: RADIUS,
|
|
179
|
+
fill: "none",
|
|
180
|
+
stroke: v.progress,
|
|
181
|
+
strokeWidth: 2,
|
|
182
|
+
strokeLinecap: "round",
|
|
183
|
+
strokeDasharray: CIRC,
|
|
184
|
+
strokeDashoffset: dashOffset,
|
|
185
|
+
style: { transform: "rotate(-90deg)", transformOrigin: "center" },
|
|
186
|
+
}),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* useToast — manages toast state and exposes a stable show() / hide() API.
|
|
194
|
+
*
|
|
195
|
+
* Usage:
|
|
196
|
+
* const { show, hide, toastProps } = useToast();
|
|
197
|
+
* <Toast {...toastProps} />
|
|
198
|
+
*
|
|
199
|
+
* show("Saved!", "success");
|
|
200
|
+
* show("Oops", "error", 6000); // custom duration
|
|
201
|
+
*/
|
|
202
|
+
export function useToast() {
|
|
203
|
+
const [state, setState] = useState({
|
|
204
|
+
visible: false,
|
|
205
|
+
message: "",
|
|
206
|
+
variant: "default",
|
|
207
|
+
duration: 4000,
|
|
208
|
+
toastId: 0,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const show = useCallback(
|
|
212
|
+
(message, variant = "default", duration = 4000) =>
|
|
213
|
+
setState((s) => ({
|
|
214
|
+
visible: true,
|
|
215
|
+
message,
|
|
216
|
+
variant,
|
|
217
|
+
duration,
|
|
218
|
+
toastId: s.toastId + 1,
|
|
219
|
+
})),
|
|
220
|
+
[],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const hide = useCallback(
|
|
224
|
+
() => setState((s) => ({ ...s, visible: false })),
|
|
225
|
+
[],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return { show, hide, toastProps: { ...state, onClose: hide } };
|
|
229
|
+
}
|