glintkit 0.1.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/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/index.js +4846 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4846 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command as Command4 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/init.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import prompts from "prompts";
|
|
9
|
+
|
|
10
|
+
// src/utils/logger.ts
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
var logger = {
|
|
13
|
+
info: (msg) => console.log(pc.cyan("\u2139"), msg),
|
|
14
|
+
success: (msg) => console.log(pc.green("\u2714"), msg),
|
|
15
|
+
warn: (msg) => console.log(pc.yellow("\u26A0"), msg),
|
|
16
|
+
error: (msg) => console.log(pc.red("\u2716"), msg),
|
|
17
|
+
break: () => console.log(""),
|
|
18
|
+
title: (msg) => console.log(pc.bold(pc.cyan(msg)))
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/utils/config.ts
|
|
22
|
+
import fs from "fs-extra";
|
|
23
|
+
import path from "path";
|
|
24
|
+
var CONFIG_FILE = "glintkit.json";
|
|
25
|
+
function getConfigPath(cwd = process.cwd()) {
|
|
26
|
+
return path.join(cwd, CONFIG_FILE);
|
|
27
|
+
}
|
|
28
|
+
function configExists(cwd = process.cwd()) {
|
|
29
|
+
return fs.existsSync(getConfigPath(cwd));
|
|
30
|
+
}
|
|
31
|
+
function readConfig(cwd = process.cwd()) {
|
|
32
|
+
const configPath = getConfigPath(cwd);
|
|
33
|
+
if (!fs.existsSync(configPath)) {
|
|
34
|
+
throw new Error("glintkit.json not found. Run `glintkit init` first.");
|
|
35
|
+
}
|
|
36
|
+
return fs.readJSONSync(configPath);
|
|
37
|
+
}
|
|
38
|
+
function writeConfig(config, cwd = process.cwd()) {
|
|
39
|
+
fs.writeJSONSync(getConfigPath(cwd), config, { spaces: 2 });
|
|
40
|
+
}
|
|
41
|
+
function detectAliasFromTsconfig(cwd = process.cwd()) {
|
|
42
|
+
const tsconfigPath = path.join(cwd, "tsconfig.json");
|
|
43
|
+
if (!fs.existsSync(tsconfigPath)) return null;
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(tsconfigPath, "utf-8");
|
|
46
|
+
const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
47
|
+
const tsconfig = JSON.parse(cleaned);
|
|
48
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
49
|
+
if (!paths) return null;
|
|
50
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
51
|
+
if (alias.endsWith("/*") && Array.isArray(targets) && targets.length > 0) {
|
|
52
|
+
const prefix = alias.slice(0, -2);
|
|
53
|
+
return prefix;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/cli/commands/init.ts
|
|
62
|
+
var initCommand = new Command("init").description("Initialize glintkit configuration").option("-y, --yes", "Skip prompts and use defaults").action(async (options) => {
|
|
63
|
+
const cwd = process.cwd();
|
|
64
|
+
if (configExists(cwd)) {
|
|
65
|
+
logger.warn("glintkit.json already exists. Overwriting...");
|
|
66
|
+
}
|
|
67
|
+
logger.title("\n glintkit init\n");
|
|
68
|
+
const detectedAlias = detectAliasFromTsconfig(cwd);
|
|
69
|
+
const aliasPrefix = detectedAlias || "@";
|
|
70
|
+
let config;
|
|
71
|
+
if (options.yes) {
|
|
72
|
+
config = {
|
|
73
|
+
aliases: {
|
|
74
|
+
components: `${aliasPrefix}/components/ui`,
|
|
75
|
+
hooks: `${aliasPrefix}/hooks`,
|
|
76
|
+
utils: `${aliasPrefix}/lib`
|
|
77
|
+
},
|
|
78
|
+
tailwind: {
|
|
79
|
+
css: "src/app/globals.css"
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
const response = await prompts([
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
name: "components",
|
|
87
|
+
message: "Components directory alias:",
|
|
88
|
+
initial: `${aliasPrefix}/components/ui`
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: "text",
|
|
92
|
+
name: "hooks",
|
|
93
|
+
message: "Hooks directory alias:",
|
|
94
|
+
initial: `${aliasPrefix}/hooks`
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "text",
|
|
98
|
+
name: "utils",
|
|
99
|
+
message: "Utils directory alias:",
|
|
100
|
+
initial: `${aliasPrefix}/lib`
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: "text",
|
|
104
|
+
name: "css",
|
|
105
|
+
message: "Tailwind CSS file path:",
|
|
106
|
+
initial: "src/app/globals.css"
|
|
107
|
+
}
|
|
108
|
+
]);
|
|
109
|
+
if (!response.components) {
|
|
110
|
+
logger.error("Init cancelled.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
config = {
|
|
114
|
+
aliases: {
|
|
115
|
+
components: response.components,
|
|
116
|
+
hooks: response.hooks,
|
|
117
|
+
utils: response.utils
|
|
118
|
+
},
|
|
119
|
+
tailwind: {
|
|
120
|
+
css: response.css
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
writeConfig(config, cwd);
|
|
125
|
+
logger.success("Created glintkit.json");
|
|
126
|
+
logger.break();
|
|
127
|
+
logger.info("Now you can add components:");
|
|
128
|
+
logger.info(" glintkit add 3d-card");
|
|
129
|
+
logger.info(" glintkit add --category 3d");
|
|
130
|
+
logger.info(" glintkit add --all");
|
|
131
|
+
logger.break();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// src/cli/commands/add.ts
|
|
135
|
+
import { Command as Command2 } from "commander";
|
|
136
|
+
import prompts2 from "prompts";
|
|
137
|
+
import path4 from "path";
|
|
138
|
+
import ora from "ora";
|
|
139
|
+
import pc2 from "picocolors";
|
|
140
|
+
|
|
141
|
+
// src/registry/components.ts
|
|
142
|
+
var REGISTRY = [
|
|
143
|
+
// Category: 3d
|
|
144
|
+
{
|
|
145
|
+
name: "3d-card",
|
|
146
|
+
category: "3d",
|
|
147
|
+
description: "3D tilt card with mouse/touch tracking and depth layers",
|
|
148
|
+
files: [
|
|
149
|
+
{
|
|
150
|
+
templateKey: "components/3d/3d-card",
|
|
151
|
+
fileName: "3d-card.tsx",
|
|
152
|
+
type: "component"
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
npmDependencies: [],
|
|
156
|
+
registryDependencies: ["cn"],
|
|
157
|
+
cssPresets: []
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "prismatic-burst",
|
|
161
|
+
category: "3d",
|
|
162
|
+
description: "WebGL prismatic light effect with customizable colors and animations",
|
|
163
|
+
files: [
|
|
164
|
+
{
|
|
165
|
+
templateKey: "components/3d/prismatic-burst",
|
|
166
|
+
fileName: "prismatic-burst.tsx",
|
|
167
|
+
type: "component"
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
npmDependencies: ["ogl"],
|
|
171
|
+
registryDependencies: [],
|
|
172
|
+
cssPresets: []
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "glass-surface",
|
|
176
|
+
category: "3d",
|
|
177
|
+
description: "SVG-based glass refraction and distortion surface",
|
|
178
|
+
files: [
|
|
179
|
+
{
|
|
180
|
+
templateKey: "components/3d/glass-surface",
|
|
181
|
+
fileName: "glass-surface.tsx",
|
|
182
|
+
type: "component"
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
npmDependencies: [],
|
|
186
|
+
registryDependencies: ["cn"],
|
|
187
|
+
cssPresets: []
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "dome-gallery",
|
|
191
|
+
category: "3d",
|
|
192
|
+
description: "3D dome/sphere photo gallery with drag and inertia",
|
|
193
|
+
files: [
|
|
194
|
+
{
|
|
195
|
+
templateKey: "components/3d/dome-gallery",
|
|
196
|
+
fileName: "dome-gallery.tsx",
|
|
197
|
+
type: "component"
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
npmDependencies: ["@use-gesture/react"],
|
|
201
|
+
registryDependencies: [],
|
|
202
|
+
cssPresets: []
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "holo-card",
|
|
206
|
+
category: "3d",
|
|
207
|
+
description: "Holographic card with rainbow shine and flip animation",
|
|
208
|
+
files: [
|
|
209
|
+
{
|
|
210
|
+
templateKey: "components/3d/holo-card",
|
|
211
|
+
fileName: "holo-card.tsx",
|
|
212
|
+
type: "component"
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
npmDependencies: [],
|
|
216
|
+
registryDependencies: ["cn"],
|
|
217
|
+
cssPresets: ["holo-card"]
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "flip-card",
|
|
221
|
+
category: "3d",
|
|
222
|
+
description: "3D flip card with tilt effect and customizable content",
|
|
223
|
+
files: [
|
|
224
|
+
{
|
|
225
|
+
templateKey: "components/3d/flip-card",
|
|
226
|
+
fileName: "flip-card.tsx",
|
|
227
|
+
type: "component"
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
npmDependencies: [],
|
|
231
|
+
registryDependencies: ["cn", "3d-card"],
|
|
232
|
+
cssPresets: []
|
|
233
|
+
},
|
|
234
|
+
// Category: motion
|
|
235
|
+
{
|
|
236
|
+
name: "counter",
|
|
237
|
+
category: "motion",
|
|
238
|
+
description: "Cyberpunk scramble counter that reveals numbers with matrix-style animation",
|
|
239
|
+
files: [
|
|
240
|
+
{
|
|
241
|
+
templateKey: "components/motion/counter",
|
|
242
|
+
fileName: "counter.tsx",
|
|
243
|
+
type: "component"
|
|
244
|
+
}
|
|
245
|
+
],
|
|
246
|
+
npmDependencies: [],
|
|
247
|
+
registryDependencies: ["cn", "use-scroll-animation"],
|
|
248
|
+
cssPresets: []
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "countdown-timer",
|
|
252
|
+
category: "motion",
|
|
253
|
+
description: "Countdown timer with multiple visual states (normal, approaching, live)",
|
|
254
|
+
files: [
|
|
255
|
+
{
|
|
256
|
+
templateKey: "components/motion/countdown-timer",
|
|
257
|
+
fileName: "countdown-timer.tsx",
|
|
258
|
+
type: "component"
|
|
259
|
+
}
|
|
260
|
+
],
|
|
261
|
+
npmDependencies: [],
|
|
262
|
+
registryDependencies: ["cn", "use-countdown"],
|
|
263
|
+
cssPresets: ["glass", "gradient-text", "animations"]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "shiny-text",
|
|
267
|
+
category: "motion",
|
|
268
|
+
description: "Animated shiny/glossy text with customizable sweep direction",
|
|
269
|
+
files: [
|
|
270
|
+
{
|
|
271
|
+
templateKey: "components/motion/shiny-text",
|
|
272
|
+
fileName: "shiny-text.tsx",
|
|
273
|
+
type: "component"
|
|
274
|
+
}
|
|
275
|
+
],
|
|
276
|
+
npmDependencies: ["motion"],
|
|
277
|
+
registryDependencies: [],
|
|
278
|
+
cssPresets: []
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "light-rays",
|
|
282
|
+
category: "motion",
|
|
283
|
+
description: "WebGL volumetric light rays from configurable origin",
|
|
284
|
+
files: [
|
|
285
|
+
{
|
|
286
|
+
templateKey: "components/motion/light-rays",
|
|
287
|
+
fileName: "light-rays.tsx",
|
|
288
|
+
type: "component"
|
|
289
|
+
}
|
|
290
|
+
],
|
|
291
|
+
npmDependencies: ["ogl"],
|
|
292
|
+
registryDependencies: [],
|
|
293
|
+
cssPresets: []
|
|
294
|
+
},
|
|
295
|
+
// Category: glass
|
|
296
|
+
{
|
|
297
|
+
name: "button",
|
|
298
|
+
category: "glass",
|
|
299
|
+
description: "Multi-variant button with glow border, gradient, and glass styles",
|
|
300
|
+
files: [
|
|
301
|
+
{
|
|
302
|
+
templateKey: "components/glass/button",
|
|
303
|
+
fileName: "button.tsx",
|
|
304
|
+
type: "component"
|
|
305
|
+
}
|
|
306
|
+
],
|
|
307
|
+
npmDependencies: ["clsx", "tailwind-merge"],
|
|
308
|
+
registryDependencies: ["cn"],
|
|
309
|
+
cssPresets: ["glow-border"]
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: "card",
|
|
313
|
+
category: "glass",
|
|
314
|
+
description: "Glass card with default, strong, gradient, and outline variants",
|
|
315
|
+
files: [
|
|
316
|
+
{
|
|
317
|
+
templateKey: "components/glass/card",
|
|
318
|
+
fileName: "card.tsx",
|
|
319
|
+
type: "component"
|
|
320
|
+
}
|
|
321
|
+
],
|
|
322
|
+
npmDependencies: ["clsx", "tailwind-merge"],
|
|
323
|
+
registryDependencies: ["cn"],
|
|
324
|
+
cssPresets: ["glass"]
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "modal",
|
|
328
|
+
category: "glass",
|
|
329
|
+
description: "Modal dialog with glass backdrop, mobile handle bar, and portal rendering",
|
|
330
|
+
files: [
|
|
331
|
+
{
|
|
332
|
+
templateKey: "components/glass/modal",
|
|
333
|
+
fileName: "modal.tsx",
|
|
334
|
+
type: "component"
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
npmDependencies: [],
|
|
338
|
+
registryDependencies: ["cn"],
|
|
339
|
+
cssPresets: ["glass", "animations"]
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "music-player",
|
|
343
|
+
category: "glass",
|
|
344
|
+
description: "Audio player with soundwave visualization and glass surface",
|
|
345
|
+
files: [
|
|
346
|
+
{
|
|
347
|
+
templateKey: "components/glass/music-player",
|
|
348
|
+
fileName: "music-player.tsx",
|
|
349
|
+
type: "component"
|
|
350
|
+
}
|
|
351
|
+
],
|
|
352
|
+
npmDependencies: [],
|
|
353
|
+
registryDependencies: ["glass-surface"],
|
|
354
|
+
cssPresets: ["animations"]
|
|
355
|
+
},
|
|
356
|
+
// Category: hooks
|
|
357
|
+
{
|
|
358
|
+
name: "use-countdown",
|
|
359
|
+
category: "hooks",
|
|
360
|
+
description: "Countdown timer hook that calculates days, hours, minutes, seconds",
|
|
361
|
+
files: [
|
|
362
|
+
{
|
|
363
|
+
templateKey: "hooks/use-countdown",
|
|
364
|
+
fileName: "use-countdown.ts",
|
|
365
|
+
type: "hook"
|
|
366
|
+
}
|
|
367
|
+
],
|
|
368
|
+
npmDependencies: [],
|
|
369
|
+
registryDependencies: [],
|
|
370
|
+
cssPresets: []
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "use-scroll-animation",
|
|
374
|
+
category: "hooks",
|
|
375
|
+
description: "IntersectionObserver-based scroll trigger hook",
|
|
376
|
+
files: [
|
|
377
|
+
{
|
|
378
|
+
templateKey: "hooks/use-scroll-animation",
|
|
379
|
+
fileName: "use-scroll-animation.ts",
|
|
380
|
+
type: "hook"
|
|
381
|
+
}
|
|
382
|
+
],
|
|
383
|
+
npmDependencies: [],
|
|
384
|
+
registryDependencies: [],
|
|
385
|
+
cssPresets: []
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "use-media-query",
|
|
389
|
+
category: "hooks",
|
|
390
|
+
description: "Responsive breakpoint detection with predefined mobile/tablet/desktop helpers",
|
|
391
|
+
files: [
|
|
392
|
+
{
|
|
393
|
+
templateKey: "hooks/use-media-query",
|
|
394
|
+
fileName: "use-media-query.ts",
|
|
395
|
+
type: "hook"
|
|
396
|
+
}
|
|
397
|
+
],
|
|
398
|
+
npmDependencies: [],
|
|
399
|
+
registryDependencies: [],
|
|
400
|
+
cssPresets: []
|
|
401
|
+
},
|
|
402
|
+
// Category: utils
|
|
403
|
+
{
|
|
404
|
+
name: "cn",
|
|
405
|
+
category: "utils",
|
|
406
|
+
description: "Tailwind CSS class merge utility (clsx + tailwind-merge)",
|
|
407
|
+
files: [
|
|
408
|
+
{
|
|
409
|
+
templateKey: "utils/cn",
|
|
410
|
+
fileName: "cn.ts",
|
|
411
|
+
type: "util"
|
|
412
|
+
}
|
|
413
|
+
],
|
|
414
|
+
npmDependencies: ["clsx", "tailwind-merge"],
|
|
415
|
+
registryDependencies: [],
|
|
416
|
+
cssPresets: []
|
|
417
|
+
}
|
|
418
|
+
];
|
|
419
|
+
function getComponent(name) {
|
|
420
|
+
return REGISTRY.find((c) => c.name === name);
|
|
421
|
+
}
|
|
422
|
+
function getComponentsByCategory(category) {
|
|
423
|
+
return REGISTRY.filter((c) => c.category === category);
|
|
424
|
+
}
|
|
425
|
+
function getAllCategories() {
|
|
426
|
+
return [...new Set(REGISTRY.map((c) => c.category))];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/utils/detect-pm.ts
|
|
430
|
+
import fs2 from "fs-extra";
|
|
431
|
+
import path2 from "path";
|
|
432
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
433
|
+
if (fs2.existsSync(path2.join(cwd, "bun.lockb")) || fs2.existsSync(path2.join(cwd, "bun.lock"))) return "bun";
|
|
434
|
+
if (fs2.existsSync(path2.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
435
|
+
if (fs2.existsSync(path2.join(cwd, "yarn.lock"))) return "yarn";
|
|
436
|
+
return "npm";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/utils/installer.ts
|
|
440
|
+
import { execSync } from "child_process";
|
|
441
|
+
function installDependencies(deps, pm, cwd = process.cwd()) {
|
|
442
|
+
if (deps.length === 0) return;
|
|
443
|
+
const commands = {
|
|
444
|
+
npm: `npm install ${deps.join(" ")}`,
|
|
445
|
+
pnpm: `pnpm add ${deps.join(" ")}`,
|
|
446
|
+
yarn: `yarn add ${deps.join(" ")}`,
|
|
447
|
+
bun: `bun add ${deps.join(" ")}`
|
|
448
|
+
};
|
|
449
|
+
const cmd = commands[pm];
|
|
450
|
+
logger.info(`Installing dependencies with ${pm}...`);
|
|
451
|
+
try {
|
|
452
|
+
execSync(cmd, { cwd, stdio: "pipe" });
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new Error(`Failed to install dependencies: ${error.message}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/utils/file-writer.ts
|
|
459
|
+
import fs3 from "fs-extra";
|
|
460
|
+
import path3 from "path";
|
|
461
|
+
|
|
462
|
+
// src/registry/__generated__/templates.ts
|
|
463
|
+
var TEMPLATES = {
|
|
464
|
+
"components/3d/3d-card": `"use client";
|
|
465
|
+
|
|
466
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
467
|
+
import React, {
|
|
468
|
+
createContext,
|
|
469
|
+
useState,
|
|
470
|
+
useContext,
|
|
471
|
+
useRef,
|
|
472
|
+
useEffect,
|
|
473
|
+
} from "react";
|
|
474
|
+
|
|
475
|
+
const MouseEnterContext = createContext<
|
|
476
|
+
[boolean, React.Dispatch<React.SetStateAction<boolean>>] | undefined
|
|
477
|
+
>(undefined);
|
|
478
|
+
|
|
479
|
+
export const CardContainer = ({
|
|
480
|
+
children,
|
|
481
|
+
className,
|
|
482
|
+
containerClassName,
|
|
483
|
+
}: {
|
|
484
|
+
children?: React.ReactNode;
|
|
485
|
+
className?: string;
|
|
486
|
+
containerClassName?: string;
|
|
487
|
+
}) => {
|
|
488
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
489
|
+
const [isMouseEntered, setIsMouseEntered] = useState(false);
|
|
490
|
+
|
|
491
|
+
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
492
|
+
if (!containerRef.current) return;
|
|
493
|
+
const { left, top, width, height } =
|
|
494
|
+
containerRef.current.getBoundingClientRect();
|
|
495
|
+
const x = (e.clientX - left - width / 2) / 25;
|
|
496
|
+
const y = (e.clientY - top - height / 2) / 25;
|
|
497
|
+
containerRef.current.style.transform = \`rotateY(\${x}deg) rotateX(\${y}deg)\`;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const handleMouseEnter = () => {
|
|
501
|
+
setIsMouseEntered(true);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const handleMouseLeave = () => {
|
|
505
|
+
if (!containerRef.current) return;
|
|
506
|
+
setIsMouseEntered(false);
|
|
507
|
+
containerRef.current.style.transform = \`rotateY(0deg) rotateX(0deg)\`;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Touch event handlers for mobile
|
|
511
|
+
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
|
512
|
+
// Don't preventDefault here - it blocks touch events on some mobile browsers
|
|
513
|
+
// Instead, use CSS touch-action: none on the element
|
|
514
|
+
setIsMouseEntered(true);
|
|
515
|
+
|
|
516
|
+
// Initial tilt based on touch position
|
|
517
|
+
if (!containerRef.current || !e.touches[0]) return;
|
|
518
|
+
const { left, top, width, height } =
|
|
519
|
+
containerRef.current.getBoundingClientRect();
|
|
520
|
+
const touch = e.touches[0];
|
|
521
|
+
const x = (touch.clientX - left - width / 2) / 15;
|
|
522
|
+
const y = (touch.clientY - top - height / 2) / 15;
|
|
523
|
+
containerRef.current.style.transform = \`rotateY(\${x}deg) rotateX(\${y}deg)\`;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
|
|
527
|
+
// Only prevent default if we're actively interacting with the card
|
|
528
|
+
// This allows the initial touch to register properly
|
|
529
|
+
if (isMouseEntered && e.cancelable) {
|
|
530
|
+
e.preventDefault();
|
|
531
|
+
}
|
|
532
|
+
if (!containerRef.current || !e.touches[0]) return;
|
|
533
|
+
const { left, top, width, height } =
|
|
534
|
+
containerRef.current.getBoundingClientRect();
|
|
535
|
+
const touch = e.touches[0];
|
|
536
|
+
const x = (touch.clientX - left - width / 2) / 15; // Increased sensitivity for touch
|
|
537
|
+
const y = (touch.clientY - top - height / 2) / 15;
|
|
538
|
+
containerRef.current.style.transform = \`rotateY(\${x}deg) rotateX(\${y}deg)\`;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const handleTouchEnd = () => {
|
|
542
|
+
if (!containerRef.current) return;
|
|
543
|
+
setIsMouseEntered(false);
|
|
544
|
+
containerRef.current.style.transform = \`rotateY(0deg) rotateX(0deg)\`;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
<MouseEnterContext.Provider value={[isMouseEntered, setIsMouseEntered]}>
|
|
549
|
+
<div
|
|
550
|
+
className={cn(
|
|
551
|
+
"py-20 flex items-center justify-center",
|
|
552
|
+
containerClassName
|
|
553
|
+
)}
|
|
554
|
+
style={{
|
|
555
|
+
perspective: "1000px",
|
|
556
|
+
}}
|
|
557
|
+
>
|
|
558
|
+
<div
|
|
559
|
+
ref={containerRef}
|
|
560
|
+
onMouseEnter={handleMouseEnter}
|
|
561
|
+
onMouseMove={handleMouseMove}
|
|
562
|
+
onMouseLeave={handleMouseLeave}
|
|
563
|
+
onTouchStart={handleTouchStart}
|
|
564
|
+
onTouchMove={handleTouchMove}
|
|
565
|
+
onTouchEnd={handleTouchEnd}
|
|
566
|
+
className={cn(
|
|
567
|
+
"flex items-center justify-center relative transition-all duration-200 ease-linear",
|
|
568
|
+
className
|
|
569
|
+
)}
|
|
570
|
+
style={{
|
|
571
|
+
transformStyle: "preserve-3d",
|
|
572
|
+
touchAction: "none", // Prevent browser touch handling for reliable tilt
|
|
573
|
+
}}
|
|
574
|
+
>
|
|
575
|
+
{children}
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
</MouseEnterContext.Provider>
|
|
579
|
+
);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
export const CardBody = ({
|
|
583
|
+
children,
|
|
584
|
+
className,
|
|
585
|
+
}: {
|
|
586
|
+
children: React.ReactNode;
|
|
587
|
+
className?: string;
|
|
588
|
+
}) => {
|
|
589
|
+
// Clone children to add transform-style: preserve-3d
|
|
590
|
+
const childrenWithPreserve3d = React.Children.map(children, (child) => {
|
|
591
|
+
if (React.isValidElement(child)) {
|
|
592
|
+
return React.cloneElement(child as React.ReactElement<{ style?: React.CSSProperties }>, {
|
|
593
|
+
style: {
|
|
594
|
+
...(child.props as { style?: React.CSSProperties }).style,
|
|
595
|
+
transformStyle: "preserve-3d",
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return child;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<div
|
|
604
|
+
className={cn("h-96 w-96", className)}
|
|
605
|
+
style={{
|
|
606
|
+
transformStyle: "preserve-3d",
|
|
607
|
+
}}
|
|
608
|
+
>
|
|
609
|
+
{childrenWithPreserve3d}
|
|
610
|
+
</div>
|
|
611
|
+
);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
export const CardItem = ({
|
|
615
|
+
as: Tag = "div",
|
|
616
|
+
children,
|
|
617
|
+
className,
|
|
618
|
+
translateX = 0,
|
|
619
|
+
translateY = 0,
|
|
620
|
+
translateZ = 0,
|
|
621
|
+
rotateX = 0,
|
|
622
|
+
rotateY = 0,
|
|
623
|
+
rotateZ = 0,
|
|
624
|
+
...rest
|
|
625
|
+
}: {
|
|
626
|
+
as?: React.ElementType;
|
|
627
|
+
children: React.ReactNode;
|
|
628
|
+
className?: string;
|
|
629
|
+
translateX?: number | string;
|
|
630
|
+
translateY?: number | string;
|
|
631
|
+
translateZ?: number | string;
|
|
632
|
+
rotateX?: number | string;
|
|
633
|
+
rotateY?: number | string;
|
|
634
|
+
rotateZ?: number | string;
|
|
635
|
+
[key: string]: unknown;
|
|
636
|
+
}) => {
|
|
637
|
+
const ref = useRef<HTMLElement>(null);
|
|
638
|
+
const [isMouseEntered] = useMouseEnter();
|
|
639
|
+
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
if (!ref.current) return;
|
|
642
|
+
|
|
643
|
+
if (isMouseEntered) {
|
|
644
|
+
ref.current.style.transform = \`translateX(\${translateX}px) translateY(\${translateY}px) translateZ(\${translateZ}px) rotateX(\${rotateX}deg) rotateY(\${rotateY}deg) rotateZ(\${rotateZ}deg)\`;
|
|
645
|
+
} else {
|
|
646
|
+
ref.current.style.transform = \`translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)\`;
|
|
647
|
+
}
|
|
648
|
+
}, [isMouseEntered, translateX, translateY, translateZ, rotateX, rotateY, rotateZ]);
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<Tag
|
|
652
|
+
ref={ref as React.RefObject<never>}
|
|
653
|
+
className={cn("w-fit transition duration-200 ease-linear", className)}
|
|
654
|
+
style={{ transformStyle: "preserve-3d" }}
|
|
655
|
+
{...rest}
|
|
656
|
+
>
|
|
657
|
+
{children}
|
|
658
|
+
</Tag>
|
|
659
|
+
);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
export const useMouseEnter = () => {
|
|
663
|
+
const context = useContext(MouseEnterContext);
|
|
664
|
+
if (context === undefined) {
|
|
665
|
+
throw new Error("useMouseEnter must be used within a MouseEnterProvider");
|
|
666
|
+
}
|
|
667
|
+
return context;
|
|
668
|
+
};
|
|
669
|
+
`,
|
|
670
|
+
"components/3d/dome-gallery": `"use client";
|
|
671
|
+
|
|
672
|
+
import { useEffect, useMemo, useRef, useCallback } from 'react';
|
|
673
|
+
import { useGesture } from '@use-gesture/react';
|
|
674
|
+
|
|
675
|
+
type ImageItem = string | { src: string; alt?: string };
|
|
676
|
+
|
|
677
|
+
type DomeGalleryProps = {
|
|
678
|
+
images?: ImageItem[];
|
|
679
|
+
fit?: number;
|
|
680
|
+
fitBasis?: 'auto' | 'min' | 'max' | 'width' | 'height';
|
|
681
|
+
minRadius?: number;
|
|
682
|
+
maxRadius?: number;
|
|
683
|
+
padFactor?: number;
|
|
684
|
+
overlayBlurColor?: string;
|
|
685
|
+
maxVerticalRotationDeg?: number;
|
|
686
|
+
dragSensitivity?: number;
|
|
687
|
+
enlargeTransitionMs?: number;
|
|
688
|
+
segments?: number;
|
|
689
|
+
dragDampening?: number;
|
|
690
|
+
openedImageWidth?: string;
|
|
691
|
+
openedImageHeight?: string;
|
|
692
|
+
imageBorderRadius?: string;
|
|
693
|
+
openedImageBorderRadius?: string;
|
|
694
|
+
grayscale?: boolean;
|
|
695
|
+
autoRotate?: boolean;
|
|
696
|
+
autoRotateSpeed?: number;
|
|
697
|
+
disableInteraction?: boolean;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
type ItemDef = {
|
|
701
|
+
src: string;
|
|
702
|
+
alt: string;
|
|
703
|
+
x: number;
|
|
704
|
+
y: number;
|
|
705
|
+
sizeX: number;
|
|
706
|
+
sizeY: number;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const DEFAULTS = {
|
|
710
|
+
maxVerticalRotationDeg: 5,
|
|
711
|
+
dragSensitivity: 20,
|
|
712
|
+
enlargeTransitionMs: 300,
|
|
713
|
+
segments: 35
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
|
717
|
+
const normalizeAngle = (d: number) => ((d % 360) + 360) % 360;
|
|
718
|
+
const wrapAngleSigned = (deg: number) => {
|
|
719
|
+
const a = (((deg + 180) % 360) + 360) % 360;
|
|
720
|
+
return a - 180;
|
|
721
|
+
};
|
|
722
|
+
const getDataNumber = (el: HTMLElement, name: string, fallback: number) => {
|
|
723
|
+
const attr = el.dataset[name] ?? el.getAttribute(\`data-\${name}\`);
|
|
724
|
+
const n = attr == null ? NaN : parseFloat(attr);
|
|
725
|
+
return Number.isFinite(n) ? n : fallback;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
function buildItems(pool: ImageItem[], seg: number): ItemDef[] {
|
|
729
|
+
const xCols = Array.from({ length: seg }, (_, i) => -37 + i * 2);
|
|
730
|
+
const evenYs = [-4, -2, 0, 2, 4];
|
|
731
|
+
const oddYs = [-3, -1, 1, 3, 5];
|
|
732
|
+
|
|
733
|
+
const coords = xCols.flatMap((x, c) => {
|
|
734
|
+
const ys = c % 2 === 0 ? evenYs : oddYs;
|
|
735
|
+
return ys.map(y => ({ x, y, sizeX: 2, sizeY: 2 }));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const totalSlots = coords.length;
|
|
739
|
+
if (pool.length === 0) {
|
|
740
|
+
return coords.map(c => ({ ...c, src: '', alt: '' }));
|
|
741
|
+
}
|
|
742
|
+
if (pool.length > totalSlots) {
|
|
743
|
+
console.warn(
|
|
744
|
+
\`[DomeGallery] Provided image count (\${pool.length}) exceeds available tiles (\${totalSlots}). Some images will not be shown.\`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const normalizedImages = pool.map(image => {
|
|
749
|
+
if (typeof image === 'string') {
|
|
750
|
+
return { src: image, alt: '' };
|
|
751
|
+
}
|
|
752
|
+
return { src: image.src || '', alt: image.alt || '' };
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const usedImages = Array.from({ length: totalSlots }, (_, i) => normalizedImages[i % normalizedImages.length]);
|
|
756
|
+
|
|
757
|
+
for (let i = 1; i < usedImages.length; i++) {
|
|
758
|
+
if (usedImages[i].src === usedImages[i - 1].src) {
|
|
759
|
+
for (let j = i + 1; j < usedImages.length; j++) {
|
|
760
|
+
if (usedImages[j].src !== usedImages[i].src) {
|
|
761
|
+
const tmp = usedImages[i];
|
|
762
|
+
usedImages[i] = usedImages[j];
|
|
763
|
+
usedImages[j] = tmp;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return coords.map((c, i) => ({
|
|
771
|
+
...c,
|
|
772
|
+
src: usedImages[i].src,
|
|
773
|
+
alt: usedImages[i].alt
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function computeItemBaseRotation(offsetX: number, offsetY: number, sizeX: number, sizeY: number, segments: number) {
|
|
778
|
+
const unit = 360 / segments / 2;
|
|
779
|
+
const rotateY = unit * (offsetX + (sizeX - 1) / 2);
|
|
780
|
+
const rotateX = unit * (offsetY - (sizeY - 1) / 2);
|
|
781
|
+
return { rotateX, rotateY };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export default function DomeGallery({
|
|
785
|
+
images = [],
|
|
786
|
+
fit = 0.8,
|
|
787
|
+
fitBasis = 'auto',
|
|
788
|
+
minRadius = 600,
|
|
789
|
+
maxRadius = Infinity,
|
|
790
|
+
padFactor = 0.25,
|
|
791
|
+
overlayBlurColor = '#060010',
|
|
792
|
+
maxVerticalRotationDeg = DEFAULTS.maxVerticalRotationDeg,
|
|
793
|
+
dragSensitivity = DEFAULTS.dragSensitivity,
|
|
794
|
+
enlargeTransitionMs = DEFAULTS.enlargeTransitionMs,
|
|
795
|
+
segments = DEFAULTS.segments,
|
|
796
|
+
dragDampening = 2,
|
|
797
|
+
openedImageWidth = '400px',
|
|
798
|
+
openedImageHeight = '400px',
|
|
799
|
+
imageBorderRadius = '30px',
|
|
800
|
+
openedImageBorderRadius = '30px',
|
|
801
|
+
grayscale = false,
|
|
802
|
+
autoRotate = false,
|
|
803
|
+
autoRotateSpeed = 0.3,
|
|
804
|
+
disableInteraction = false
|
|
805
|
+
}: DomeGalleryProps) {
|
|
806
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
807
|
+
const mainRef = useRef<HTMLDivElement>(null);
|
|
808
|
+
const sphereRef = useRef<HTMLDivElement>(null);
|
|
809
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
810
|
+
const viewerRef = useRef<HTMLDivElement>(null);
|
|
811
|
+
const scrimRef = useRef<HTMLDivElement>(null);
|
|
812
|
+
const focusedElRef = useRef<HTMLElement | null>(null);
|
|
813
|
+
const originalTilePositionRef = useRef<{
|
|
814
|
+
left: number;
|
|
815
|
+
top: number;
|
|
816
|
+
width: number;
|
|
817
|
+
height: number;
|
|
818
|
+
} | null>(null);
|
|
819
|
+
|
|
820
|
+
const rotationRef = useRef({ x: 0, y: 0 });
|
|
821
|
+
const startRotRef = useRef({ x: 0, y: 0 });
|
|
822
|
+
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
|
823
|
+
const draggingRef = useRef(false);
|
|
824
|
+
const cancelTapRef = useRef(false);
|
|
825
|
+
const movedRef = useRef(false);
|
|
826
|
+
const inertiaRAF = useRef<number | null>(null);
|
|
827
|
+
const pointerTypeRef = useRef<'mouse' | 'pen' | 'touch'>('mouse');
|
|
828
|
+
const tapTargetRef = useRef<HTMLElement | null>(null);
|
|
829
|
+
const openingRef = useRef(false);
|
|
830
|
+
const openStartedAtRef = useRef(0);
|
|
831
|
+
const lastDragEndAt = useRef(0);
|
|
832
|
+
|
|
833
|
+
const scrollLockedRef = useRef(false);
|
|
834
|
+
const lockScroll = useCallback(() => {
|
|
835
|
+
if (scrollLockedRef.current) return;
|
|
836
|
+
scrollLockedRef.current = true;
|
|
837
|
+
document.body.classList.add('dg-scroll-lock');
|
|
838
|
+
}, []);
|
|
839
|
+
const unlockScroll = useCallback(() => {
|
|
840
|
+
if (!scrollLockedRef.current) return;
|
|
841
|
+
if (rootRef.current?.getAttribute('data-enlarging') === 'true') return;
|
|
842
|
+
scrollLockedRef.current = false;
|
|
843
|
+
document.body.classList.remove('dg-scroll-lock');
|
|
844
|
+
}, []);
|
|
845
|
+
|
|
846
|
+
const items = useMemo(() => buildItems(images, segments), [images, segments]);
|
|
847
|
+
|
|
848
|
+
const applyTransform = (xDeg: number, yDeg: number) => {
|
|
849
|
+
const el = sphereRef.current;
|
|
850
|
+
if (el) {
|
|
851
|
+
el.style.transform = \`translateZ(calc(var(--radius) * -1)) rotateX(\${xDeg}deg) rotateY(\${yDeg}deg)\`;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const lockedRadiusRef = useRef<number | null>(null);
|
|
856
|
+
|
|
857
|
+
useEffect(() => {
|
|
858
|
+
const root = rootRef.current;
|
|
859
|
+
if (!root) return;
|
|
860
|
+
const ro = new ResizeObserver(entries => {
|
|
861
|
+
const cr = entries[0].contentRect;
|
|
862
|
+
const w = Math.max(1, cr.width),
|
|
863
|
+
h = Math.max(1, cr.height);
|
|
864
|
+
const minDim = Math.min(w, h),
|
|
865
|
+
maxDim = Math.max(w, h),
|
|
866
|
+
aspect = w / h;
|
|
867
|
+
let basis: number;
|
|
868
|
+
switch (fitBasis) {
|
|
869
|
+
case 'min':
|
|
870
|
+
basis = minDim;
|
|
871
|
+
break;
|
|
872
|
+
case 'max':
|
|
873
|
+
basis = maxDim;
|
|
874
|
+
break;
|
|
875
|
+
case 'width':
|
|
876
|
+
basis = w;
|
|
877
|
+
break;
|
|
878
|
+
case 'height':
|
|
879
|
+
basis = h;
|
|
880
|
+
break;
|
|
881
|
+
default:
|
|
882
|
+
basis = aspect >= 1.3 ? w : minDim;
|
|
883
|
+
}
|
|
884
|
+
let radius = basis * fit;
|
|
885
|
+
const heightGuard = h * 1.35;
|
|
886
|
+
radius = Math.min(radius, heightGuard);
|
|
887
|
+
radius = clamp(radius, minRadius, maxRadius);
|
|
888
|
+
lockedRadiusRef.current = Math.round(radius);
|
|
889
|
+
|
|
890
|
+
const viewerPad = Math.max(8, Math.round(minDim * padFactor));
|
|
891
|
+
root.style.setProperty('--radius', \`\${lockedRadiusRef.current}px\`);
|
|
892
|
+
root.style.setProperty('--viewer-pad', \`\${viewerPad}px\`);
|
|
893
|
+
root.style.setProperty('--overlay-blur-color', overlayBlurColor);
|
|
894
|
+
root.style.setProperty('--tile-radius', imageBorderRadius);
|
|
895
|
+
root.style.setProperty('--enlarge-radius', openedImageBorderRadius);
|
|
896
|
+
root.style.setProperty('--image-filter', grayscale ? 'grayscale(1)' : 'none');
|
|
897
|
+
applyTransform(rotationRef.current.x, rotationRef.current.y);
|
|
898
|
+
|
|
899
|
+
const enlargedOverlay = viewerRef.current?.querySelector('.enlarge') as HTMLElement;
|
|
900
|
+
if (enlargedOverlay && frameRef.current && mainRef.current) {
|
|
901
|
+
const frameR = frameRef.current.getBoundingClientRect();
|
|
902
|
+
const mainR = mainRef.current.getBoundingClientRect();
|
|
903
|
+
|
|
904
|
+
const hasCustomSize = openedImageWidth && openedImageHeight;
|
|
905
|
+
if (hasCustomSize) {
|
|
906
|
+
const tempDiv = document.createElement('div');
|
|
907
|
+
tempDiv.style.cssText = \`position: absolute; width: \${openedImageWidth}; height: \${openedImageHeight}; visibility: hidden;\`;
|
|
908
|
+
document.body.appendChild(tempDiv);
|
|
909
|
+
const tempRect = tempDiv.getBoundingClientRect();
|
|
910
|
+
document.body.removeChild(tempDiv);
|
|
911
|
+
|
|
912
|
+
const centeredLeft = frameR.left - mainR.left + (frameR.width - tempRect.width) / 2;
|
|
913
|
+
const centeredTop = frameR.top - mainR.top + (frameR.height - tempRect.height) / 2;
|
|
914
|
+
|
|
915
|
+
enlargedOverlay.style.left = \`\${centeredLeft}px\`;
|
|
916
|
+
enlargedOverlay.style.top = \`\${centeredTop}px\`;
|
|
917
|
+
} else {
|
|
918
|
+
enlargedOverlay.style.left = \`\${frameR.left - mainR.left}px\`;
|
|
919
|
+
enlargedOverlay.style.top = \`\${frameR.top - mainR.top}px\`;
|
|
920
|
+
enlargedOverlay.style.width = \`\${frameR.width}px\`;
|
|
921
|
+
enlargedOverlay.style.height = \`\${frameR.height}px\`;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
ro.observe(root);
|
|
926
|
+
return () => ro.disconnect();
|
|
927
|
+
}, [
|
|
928
|
+
fit,
|
|
929
|
+
fitBasis,
|
|
930
|
+
minRadius,
|
|
931
|
+
maxRadius,
|
|
932
|
+
padFactor,
|
|
933
|
+
overlayBlurColor,
|
|
934
|
+
grayscale,
|
|
935
|
+
imageBorderRadius,
|
|
936
|
+
openedImageBorderRadius,
|
|
937
|
+
openedImageWidth,
|
|
938
|
+
openedImageHeight
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
applyTransform(rotationRef.current.x, rotationRef.current.y);
|
|
943
|
+
}, []);
|
|
944
|
+
|
|
945
|
+
const stopInertia = useCallback(() => {
|
|
946
|
+
if (inertiaRAF.current) {
|
|
947
|
+
cancelAnimationFrame(inertiaRAF.current);
|
|
948
|
+
inertiaRAF.current = null;
|
|
949
|
+
}
|
|
950
|
+
}, []);
|
|
951
|
+
|
|
952
|
+
const startInertia = useCallback(
|
|
953
|
+
(vx: number, vy: number) => {
|
|
954
|
+
const MAX_V = 1.4;
|
|
955
|
+
let vX = clamp(vx, -MAX_V, MAX_V) * 80;
|
|
956
|
+
let vY = clamp(vy, -MAX_V, MAX_V) * 80;
|
|
957
|
+
let frames = 0;
|
|
958
|
+
const d = clamp(dragDampening ?? 0.6, 0, 1);
|
|
959
|
+
const frictionMul = 0.94 + 0.055 * d;
|
|
960
|
+
const stopThreshold = 0.015 - 0.01 * d;
|
|
961
|
+
const maxFrames = Math.round(90 + 270 * d);
|
|
962
|
+
const step = () => {
|
|
963
|
+
vX *= frictionMul;
|
|
964
|
+
vY *= frictionMul;
|
|
965
|
+
if (Math.abs(vX) < stopThreshold && Math.abs(vY) < stopThreshold) {
|
|
966
|
+
inertiaRAF.current = null;
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (++frames > maxFrames) {
|
|
970
|
+
inertiaRAF.current = null;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const nextX = clamp(rotationRef.current.x - vY / 200, -maxVerticalRotationDeg, maxVerticalRotationDeg);
|
|
974
|
+
const nextY = wrapAngleSigned(rotationRef.current.y + vX / 200);
|
|
975
|
+
rotationRef.current = { x: nextX, y: nextY };
|
|
976
|
+
applyTransform(nextX, nextY);
|
|
977
|
+
inertiaRAF.current = requestAnimationFrame(step);
|
|
978
|
+
};
|
|
979
|
+
stopInertia();
|
|
980
|
+
inertiaRAF.current = requestAnimationFrame(step);
|
|
981
|
+
},
|
|
982
|
+
[dragDampening, maxVerticalRotationDeg, stopInertia]
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
// Auto-rotation effect with visibility optimization
|
|
986
|
+
const autoRotateRAF = useRef<number | null>(null);
|
|
987
|
+
const isVisibleRef = useRef(false);
|
|
988
|
+
const isScrollingRef = useRef(false);
|
|
989
|
+
|
|
990
|
+
// IntersectionObserver to pause animation when not visible
|
|
991
|
+
useEffect(() => {
|
|
992
|
+
const root = rootRef.current;
|
|
993
|
+
if (!root) return;
|
|
994
|
+
|
|
995
|
+
const observer = new IntersectionObserver(
|
|
996
|
+
(entries) => {
|
|
997
|
+
isVisibleRef.current = entries[0].isIntersecting;
|
|
998
|
+
},
|
|
999
|
+
{ threshold: 0.1 }
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
observer.observe(root);
|
|
1003
|
+
return () => observer.disconnect();
|
|
1004
|
+
}, []);
|
|
1005
|
+
|
|
1006
|
+
// Pause animation during scroll for better performance
|
|
1007
|
+
useEffect(() => {
|
|
1008
|
+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
1009
|
+
|
|
1010
|
+
const handleScroll = () => {
|
|
1011
|
+
isScrollingRef.current = true;
|
|
1012
|
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
1013
|
+
scrollTimeout = setTimeout(() => {
|
|
1014
|
+
isScrollingRef.current = false;
|
|
1015
|
+
}, 150);
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
1019
|
+
return () => {
|
|
1020
|
+
window.removeEventListener('scroll', handleScroll);
|
|
1021
|
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
1022
|
+
};
|
|
1023
|
+
}, []);
|
|
1024
|
+
|
|
1025
|
+
useEffect(() => {
|
|
1026
|
+
if (!autoRotate) {
|
|
1027
|
+
if (autoRotateRAF.current) {
|
|
1028
|
+
cancelAnimationFrame(autoRotateRAF.current);
|
|
1029
|
+
autoRotateRAF.current = null;
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const animate = () => {
|
|
1035
|
+
// Don't rotate while dragging, scrolling, when an image is focused, or when not visible
|
|
1036
|
+
if (draggingRef.current || focusedElRef.current || inertiaRAF.current || !isVisibleRef.current || isScrollingRef.current) {
|
|
1037
|
+
autoRotateRAF.current = requestAnimationFrame(animate);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const nextY = wrapAngleSigned(rotationRef.current.y + autoRotateSpeed);
|
|
1042
|
+
rotationRef.current = { x: rotationRef.current.x, y: nextY };
|
|
1043
|
+
applyTransform(rotationRef.current.x, nextY);
|
|
1044
|
+
autoRotateRAF.current = requestAnimationFrame(animate);
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
autoRotateRAF.current = requestAnimationFrame(animate);
|
|
1048
|
+
|
|
1049
|
+
return () => {
|
|
1050
|
+
if (autoRotateRAF.current) {
|
|
1051
|
+
cancelAnimationFrame(autoRotateRAF.current);
|
|
1052
|
+
autoRotateRAF.current = null;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}, [autoRotate, autoRotateSpeed]);
|
|
1056
|
+
|
|
1057
|
+
useGesture(
|
|
1058
|
+
{
|
|
1059
|
+
onDragStart: ({ event }) => {
|
|
1060
|
+
if (disableInteraction) return;
|
|
1061
|
+
if (focusedElRef.current) return;
|
|
1062
|
+
stopInertia();
|
|
1063
|
+
|
|
1064
|
+
const evt = event as PointerEvent;
|
|
1065
|
+
pointerTypeRef.current = (evt.pointerType as 'mouse' | 'pen' | 'touch') || 'mouse';
|
|
1066
|
+
if (pointerTypeRef.current === 'touch') evt.preventDefault();
|
|
1067
|
+
if (pointerTypeRef.current === 'touch') lockScroll();
|
|
1068
|
+
draggingRef.current = true;
|
|
1069
|
+
cancelTapRef.current = false;
|
|
1070
|
+
movedRef.current = false;
|
|
1071
|
+
startRotRef.current = { ...rotationRef.current };
|
|
1072
|
+
startPosRef.current = { x: evt.clientX, y: evt.clientY };
|
|
1073
|
+
const potential = (evt.target as Element).closest?.('.item__image') as HTMLElement | null;
|
|
1074
|
+
tapTargetRef.current = potential || null;
|
|
1075
|
+
},
|
|
1076
|
+
onDrag: ({ event, last, velocity: velArr = [0, 0], direction: dirArr = [0, 0], movement }) => {
|
|
1077
|
+
if (focusedElRef.current || !draggingRef.current || !startPosRef.current) return;
|
|
1078
|
+
|
|
1079
|
+
const evt = event as PointerEvent;
|
|
1080
|
+
if (pointerTypeRef.current === 'touch') evt.preventDefault();
|
|
1081
|
+
|
|
1082
|
+
const dxTotal = evt.clientX - startPosRef.current.x;
|
|
1083
|
+
const dyTotal = evt.clientY - startPosRef.current.y;
|
|
1084
|
+
|
|
1085
|
+
if (!movedRef.current) {
|
|
1086
|
+
const dist2 = dxTotal * dxTotal + dyTotal * dyTotal;
|
|
1087
|
+
if (dist2 > 16) movedRef.current = true;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const nextX = clamp(
|
|
1091
|
+
startRotRef.current.x - dyTotal / dragSensitivity,
|
|
1092
|
+
-maxVerticalRotationDeg,
|
|
1093
|
+
maxVerticalRotationDeg
|
|
1094
|
+
);
|
|
1095
|
+
const nextY = startRotRef.current.y + dxTotal / dragSensitivity;
|
|
1096
|
+
|
|
1097
|
+
const cur = rotationRef.current;
|
|
1098
|
+
if (cur.x !== nextX || cur.y !== nextY) {
|
|
1099
|
+
rotationRef.current = { x: nextX, y: nextY };
|
|
1100
|
+
applyTransform(nextX, nextY);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (last) {
|
|
1104
|
+
draggingRef.current = false;
|
|
1105
|
+
let isTap = false;
|
|
1106
|
+
|
|
1107
|
+
if (startPosRef.current) {
|
|
1108
|
+
const dx = evt.clientX - startPosRef.current.x;
|
|
1109
|
+
const dy = evt.clientY - startPosRef.current.y;
|
|
1110
|
+
const dist2 = dx * dx + dy * dy;
|
|
1111
|
+
const TAP_THRESH_PX = pointerTypeRef.current === 'touch' ? 10 : 6;
|
|
1112
|
+
if (dist2 <= TAP_THRESH_PX * TAP_THRESH_PX) {
|
|
1113
|
+
isTap = true;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
let [vMagX, vMagY] = velArr;
|
|
1118
|
+
const [dirX, dirY] = dirArr;
|
|
1119
|
+
let vx = vMagX * dirX;
|
|
1120
|
+
let vy = vMagY * dirY;
|
|
1121
|
+
|
|
1122
|
+
if (!isTap && Math.abs(vx) < 0.001 && Math.abs(vy) < 0.001 && Array.isArray(movement)) {
|
|
1123
|
+
const [mx, my] = movement;
|
|
1124
|
+
vx = (mx / dragSensitivity) * 0.02;
|
|
1125
|
+
vy = (my / dragSensitivity) * 0.02;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (!isTap && (Math.abs(vx) > 0.005 || Math.abs(vy) > 0.005)) {
|
|
1129
|
+
startInertia(vx, vy);
|
|
1130
|
+
}
|
|
1131
|
+
startPosRef.current = null;
|
|
1132
|
+
cancelTapRef.current = !isTap;
|
|
1133
|
+
|
|
1134
|
+
if (isTap && tapTargetRef.current && !focusedElRef.current) {
|
|
1135
|
+
openItemFromElement(tapTargetRef.current);
|
|
1136
|
+
}
|
|
1137
|
+
tapTargetRef.current = null;
|
|
1138
|
+
|
|
1139
|
+
if (cancelTapRef.current) setTimeout(() => (cancelTapRef.current = false), 120);
|
|
1140
|
+
if (pointerTypeRef.current === 'touch') unlockScroll();
|
|
1141
|
+
if (movedRef.current) lastDragEndAt.current = performance.now();
|
|
1142
|
+
movedRef.current = false;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
{ target: mainRef, eventOptions: { passive: false } }
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
useEffect(() => {
|
|
1150
|
+
const scrim = scrimRef.current;
|
|
1151
|
+
if (!scrim) return;
|
|
1152
|
+
|
|
1153
|
+
const close = () => {
|
|
1154
|
+
if (performance.now() - openStartedAtRef.current < 250) return;
|
|
1155
|
+
const el = focusedElRef.current;
|
|
1156
|
+
if (!el) return;
|
|
1157
|
+
const parent = el.parentElement as HTMLElement;
|
|
1158
|
+
const overlay = viewerRef.current?.querySelector('.enlarge') as HTMLElement | null;
|
|
1159
|
+
if (!overlay) return;
|
|
1160
|
+
|
|
1161
|
+
const refDiv = parent.querySelector('.item__image--reference') as HTMLElement | null;
|
|
1162
|
+
|
|
1163
|
+
const originalPos = originalTilePositionRef.current;
|
|
1164
|
+
if (!originalPos) {
|
|
1165
|
+
overlay.remove();
|
|
1166
|
+
if (refDiv) refDiv.remove();
|
|
1167
|
+
parent.style.setProperty('--rot-y-delta', \`0deg\`);
|
|
1168
|
+
parent.style.setProperty('--rot-x-delta', \`0deg\`);
|
|
1169
|
+
el.style.visibility = '';
|
|
1170
|
+
el.style.zIndex = '0';
|
|
1171
|
+
focusedElRef.current = null;
|
|
1172
|
+
rootRef.current?.removeAttribute('data-enlarging');
|
|
1173
|
+
openingRef.current = false;
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const currentRect = overlay.getBoundingClientRect();
|
|
1178
|
+
const rootRect = rootRef.current!.getBoundingClientRect();
|
|
1179
|
+
|
|
1180
|
+
const originalPosRelativeToRoot = {
|
|
1181
|
+
left: originalPos.left - rootRect.left,
|
|
1182
|
+
top: originalPos.top - rootRect.top,
|
|
1183
|
+
width: originalPos.width,
|
|
1184
|
+
height: originalPos.height
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
const overlayRelativeToRoot = {
|
|
1188
|
+
left: currentRect.left - rootRect.left,
|
|
1189
|
+
top: currentRect.top - rootRect.top,
|
|
1190
|
+
width: currentRect.width,
|
|
1191
|
+
height: currentRect.height
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
const animatingOverlay = document.createElement('div');
|
|
1195
|
+
animatingOverlay.className = 'enlarge-closing';
|
|
1196
|
+
animatingOverlay.style.cssText = \`
|
|
1197
|
+
position: absolute;
|
|
1198
|
+
left: \${overlayRelativeToRoot.left}px;
|
|
1199
|
+
top: \${overlayRelativeToRoot.top}px;
|
|
1200
|
+
width: \${overlayRelativeToRoot.width}px;
|
|
1201
|
+
height: \${overlayRelativeToRoot.height}px;
|
|
1202
|
+
z-index: 9999;
|
|
1203
|
+
border-radius: \${openedImageBorderRadius};
|
|
1204
|
+
overflow: hidden;
|
|
1205
|
+
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
|
1206
|
+
transition: all \${enlargeTransitionMs}ms ease-out;
|
|
1207
|
+
pointer-events: none;
|
|
1208
|
+
margin: 0;
|
|
1209
|
+
transform: none;
|
|
1210
|
+
filter: \${grayscale ? 'grayscale(1)' : 'none'};
|
|
1211
|
+
\`;
|
|
1212
|
+
|
|
1213
|
+
const originalImg = overlay.querySelector('img');
|
|
1214
|
+
if (originalImg) {
|
|
1215
|
+
const img = originalImg.cloneNode() as HTMLImageElement;
|
|
1216
|
+
img.style.cssText = 'width: 100%; height: 100%; object-fit: cover;';
|
|
1217
|
+
animatingOverlay.appendChild(img);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
overlay.remove();
|
|
1221
|
+
rootRef.current!.appendChild(animatingOverlay);
|
|
1222
|
+
|
|
1223
|
+
void animatingOverlay.getBoundingClientRect();
|
|
1224
|
+
|
|
1225
|
+
requestAnimationFrame(() => {
|
|
1226
|
+
animatingOverlay.style.left = originalPosRelativeToRoot.left + 'px';
|
|
1227
|
+
animatingOverlay.style.top = originalPosRelativeToRoot.top + 'px';
|
|
1228
|
+
animatingOverlay.style.width = originalPosRelativeToRoot.width + 'px';
|
|
1229
|
+
animatingOverlay.style.height = originalPosRelativeToRoot.height + 'px';
|
|
1230
|
+
animatingOverlay.style.opacity = '0';
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
const cleanup = () => {
|
|
1234
|
+
animatingOverlay.remove();
|
|
1235
|
+
originalTilePositionRef.current = null;
|
|
1236
|
+
|
|
1237
|
+
if (refDiv) refDiv.remove();
|
|
1238
|
+
parent.style.transition = 'none';
|
|
1239
|
+
el.style.transition = 'none';
|
|
1240
|
+
|
|
1241
|
+
parent.style.setProperty('--rot-y-delta', \`0deg\`);
|
|
1242
|
+
parent.style.setProperty('--rot-x-delta', \`0deg\`);
|
|
1243
|
+
|
|
1244
|
+
requestAnimationFrame(() => {
|
|
1245
|
+
el.style.visibility = '';
|
|
1246
|
+
el.style.opacity = '0';
|
|
1247
|
+
el.style.zIndex = '0';
|
|
1248
|
+
focusedElRef.current = null;
|
|
1249
|
+
rootRef.current?.removeAttribute('data-enlarging');
|
|
1250
|
+
|
|
1251
|
+
requestAnimationFrame(() => {
|
|
1252
|
+
parent.style.transition = '';
|
|
1253
|
+
el.style.transition = 'opacity 300ms ease-out';
|
|
1254
|
+
|
|
1255
|
+
requestAnimationFrame(() => {
|
|
1256
|
+
el.style.opacity = '1';
|
|
1257
|
+
setTimeout(() => {
|
|
1258
|
+
el.style.transition = '';
|
|
1259
|
+
el.style.opacity = '';
|
|
1260
|
+
openingRef.current = false;
|
|
1261
|
+
if (!draggingRef.current && rootRef.current?.getAttribute('data-enlarging') !== 'true') {
|
|
1262
|
+
document.body.classList.remove('dg-scroll-lock');
|
|
1263
|
+
}
|
|
1264
|
+
}, 300);
|
|
1265
|
+
});
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
animatingOverlay.addEventListener('transitionend', cleanup, {
|
|
1271
|
+
once: true
|
|
1272
|
+
});
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
scrim.addEventListener('click', close);
|
|
1276
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1277
|
+
if (e.key === 'Escape') close();
|
|
1278
|
+
};
|
|
1279
|
+
window.addEventListener('keydown', onKey);
|
|
1280
|
+
|
|
1281
|
+
return () => {
|
|
1282
|
+
scrim.removeEventListener('click', close);
|
|
1283
|
+
window.removeEventListener('keydown', onKey);
|
|
1284
|
+
};
|
|
1285
|
+
}, [enlargeTransitionMs, openedImageBorderRadius, grayscale]);
|
|
1286
|
+
|
|
1287
|
+
const openItemFromElement = (el: HTMLElement) => {
|
|
1288
|
+
if (openingRef.current) return;
|
|
1289
|
+
openingRef.current = true;
|
|
1290
|
+
openStartedAtRef.current = performance.now();
|
|
1291
|
+
lockScroll();
|
|
1292
|
+
const parent = el.parentElement as HTMLElement;
|
|
1293
|
+
focusedElRef.current = el;
|
|
1294
|
+
el.setAttribute('data-focused', 'true');
|
|
1295
|
+
const offsetX = getDataNumber(parent, 'offsetX', 0);
|
|
1296
|
+
const offsetY = getDataNumber(parent, 'offsetY', 0);
|
|
1297
|
+
const sizeX = getDataNumber(parent, 'sizeX', 2);
|
|
1298
|
+
const sizeY = getDataNumber(parent, 'sizeY', 2);
|
|
1299
|
+
const parentRot = computeItemBaseRotation(offsetX, offsetY, sizeX, sizeY, segments);
|
|
1300
|
+
const parentY = normalizeAngle(parentRot.rotateY);
|
|
1301
|
+
const globalY = normalizeAngle(rotationRef.current.y);
|
|
1302
|
+
let rotY = -(parentY + globalY) % 360;
|
|
1303
|
+
if (rotY < -180) rotY += 360;
|
|
1304
|
+
const rotX = -parentRot.rotateX - rotationRef.current.x;
|
|
1305
|
+
parent.style.setProperty('--rot-y-delta', \`\${rotY}deg\`);
|
|
1306
|
+
parent.style.setProperty('--rot-x-delta', \`\${rotX}deg\`);
|
|
1307
|
+
const refDiv = document.createElement('div');
|
|
1308
|
+
refDiv.className = 'item__image item__image--reference opacity-0';
|
|
1309
|
+
refDiv.style.transform = \`rotateX(\${-parentRot.rotateX}deg) rotateY(\${-parentRot.rotateY}deg)\`;
|
|
1310
|
+
parent.appendChild(refDiv);
|
|
1311
|
+
|
|
1312
|
+
void refDiv.offsetHeight;
|
|
1313
|
+
|
|
1314
|
+
const tileR = refDiv.getBoundingClientRect();
|
|
1315
|
+
const mainR = mainRef.current?.getBoundingClientRect();
|
|
1316
|
+
const frameR = frameRef.current?.getBoundingClientRect();
|
|
1317
|
+
|
|
1318
|
+
if (!mainR || !frameR || tileR.width <= 0 || tileR.height <= 0) {
|
|
1319
|
+
openingRef.current = false;
|
|
1320
|
+
focusedElRef.current = null;
|
|
1321
|
+
parent.removeChild(refDiv);
|
|
1322
|
+
unlockScroll();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
originalTilePositionRef.current = {
|
|
1327
|
+
left: tileR.left,
|
|
1328
|
+
top: tileR.top,
|
|
1329
|
+
width: tileR.width,
|
|
1330
|
+
height: tileR.height
|
|
1331
|
+
};
|
|
1332
|
+
el.style.visibility = 'hidden';
|
|
1333
|
+
el.style.zIndex = '0';
|
|
1334
|
+
const overlay = document.createElement('div');
|
|
1335
|
+
overlay.className = 'enlarge';
|
|
1336
|
+
overlay.style.cssText = \`position:absolute; left:\${frameR.left - mainR.left}px; top:\${frameR.top - mainR.top}px; width:\${frameR.width}px; height:\${frameR.height}px; opacity:0; z-index:30; will-change:transform,opacity; transform-origin:top left; transition:transform \${enlargeTransitionMs}ms ease, opacity \${enlargeTransitionMs}ms ease; border-radius:\${openedImageBorderRadius}; overflow:hidden; box-shadow:0 10px 30px rgba(0,0,0,.35);\`;
|
|
1337
|
+
const rawSrc = parent.dataset.src || (el.querySelector('img') as HTMLImageElement)?.src || '';
|
|
1338
|
+
const rawAlt = parent.dataset.alt || (el.querySelector('img') as HTMLImageElement)?.alt || '';
|
|
1339
|
+
const img = document.createElement('img');
|
|
1340
|
+
img.src = rawSrc;
|
|
1341
|
+
img.alt = rawAlt;
|
|
1342
|
+
img.style.cssText = \`width:100%; height:100%; object-fit:cover; filter:\${grayscale ? 'grayscale(1)' : 'none'};\`;
|
|
1343
|
+
overlay.appendChild(img);
|
|
1344
|
+
viewerRef.current!.appendChild(overlay);
|
|
1345
|
+
const tx0 = tileR.left - frameR.left;
|
|
1346
|
+
const ty0 = tileR.top - frameR.top;
|
|
1347
|
+
const sx0 = tileR.width / frameR.width;
|
|
1348
|
+
const sy0 = tileR.height / frameR.height;
|
|
1349
|
+
|
|
1350
|
+
const validSx0 = isFinite(sx0) && sx0 > 0 ? sx0 : 1;
|
|
1351
|
+
const validSy0 = isFinite(sy0) && sy0 > 0 ? sy0 : 1;
|
|
1352
|
+
|
|
1353
|
+
overlay.style.transform = \`translate(\${tx0}px, \${ty0}px) scale(\${validSx0}, \${validSy0})\`;
|
|
1354
|
+
setTimeout(() => {
|
|
1355
|
+
if (!overlay.parentElement) return;
|
|
1356
|
+
overlay.style.opacity = '1';
|
|
1357
|
+
overlay.style.transform = 'translate(0px, 0px) scale(1, 1)';
|
|
1358
|
+
rootRef.current?.setAttribute('data-enlarging', 'true');
|
|
1359
|
+
}, 16);
|
|
1360
|
+
const wantsResize = openedImageWidth || openedImageHeight;
|
|
1361
|
+
if (wantsResize) {
|
|
1362
|
+
const onFirstEnd = (ev: TransitionEvent) => {
|
|
1363
|
+
if (ev.propertyName !== 'transform') return;
|
|
1364
|
+
overlay.removeEventListener('transitionend', onFirstEnd);
|
|
1365
|
+
const prevTransition = overlay.style.transition;
|
|
1366
|
+
overlay.style.transition = 'none';
|
|
1367
|
+
const tempWidth = openedImageWidth || \`\${frameR.width}px\`;
|
|
1368
|
+
const tempHeight = openedImageHeight || \`\${frameR.height}px\`;
|
|
1369
|
+
overlay.style.width = tempWidth;
|
|
1370
|
+
overlay.style.height = tempHeight;
|
|
1371
|
+
const newRect = overlay.getBoundingClientRect();
|
|
1372
|
+
overlay.style.width = frameR.width + 'px';
|
|
1373
|
+
overlay.style.height = frameR.height + 'px';
|
|
1374
|
+
void overlay.offsetWidth;
|
|
1375
|
+
overlay.style.transition = \`left \${enlargeTransitionMs}ms ease, top \${enlargeTransitionMs}ms ease, width \${enlargeTransitionMs}ms ease, height \${enlargeTransitionMs}ms ease\`;
|
|
1376
|
+
const centeredLeft = frameR.left - mainR.left + (frameR.width - newRect.width) / 2;
|
|
1377
|
+
const centeredTop = frameR.top - mainR.top + (frameR.height - newRect.height) / 2;
|
|
1378
|
+
requestAnimationFrame(() => {
|
|
1379
|
+
overlay.style.left = \`\${centeredLeft}px\`;
|
|
1380
|
+
overlay.style.top = \`\${centeredTop}px\`;
|
|
1381
|
+
overlay.style.width = tempWidth;
|
|
1382
|
+
overlay.style.height = tempHeight;
|
|
1383
|
+
});
|
|
1384
|
+
const cleanupSecond = () => {
|
|
1385
|
+
overlay.removeEventListener('transitionend', cleanupSecond);
|
|
1386
|
+
overlay.style.transition = prevTransition;
|
|
1387
|
+
};
|
|
1388
|
+
overlay.addEventListener('transitionend', cleanupSecond, {
|
|
1389
|
+
once: true
|
|
1390
|
+
});
|
|
1391
|
+
};
|
|
1392
|
+
overlay.addEventListener('transitionend', onFirstEnd);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
useEffect(() => {
|
|
1397
|
+
return () => {
|
|
1398
|
+
document.body.classList.remove('dg-scroll-lock');
|
|
1399
|
+
};
|
|
1400
|
+
}, []);
|
|
1401
|
+
|
|
1402
|
+
const cssStyles = \`
|
|
1403
|
+
.sphere-root {
|
|
1404
|
+
--radius: 520px;
|
|
1405
|
+
--viewer-pad: 72px;
|
|
1406
|
+
--circ: calc(var(--radius) * 3.14);
|
|
1407
|
+
--rot-y: calc((360deg / var(--segments-x)) / 2);
|
|
1408
|
+
--rot-x: calc((360deg / var(--segments-y)) / 2);
|
|
1409
|
+
--item-width: calc(var(--circ) / var(--segments-x));
|
|
1410
|
+
--item-height: calc(var(--circ) / var(--segments-y));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.sphere-root * { box-sizing: border-box; }
|
|
1414
|
+
.sphere, .sphere-item, .item__image { transform-style: preserve-3d; }
|
|
1415
|
+
|
|
1416
|
+
.stage {
|
|
1417
|
+
width: 100%;
|
|
1418
|
+
height: 100%;
|
|
1419
|
+
display: grid;
|
|
1420
|
+
place-items: center;
|
|
1421
|
+
position: absolute;
|
|
1422
|
+
inset: 0;
|
|
1423
|
+
margin: auto;
|
|
1424
|
+
perspective: calc(var(--radius) * 2);
|
|
1425
|
+
perspective-origin: 50% 50%;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
.sphere {
|
|
1429
|
+
transform: translateZ(calc(var(--radius) * -1));
|
|
1430
|
+
will-change: transform;
|
|
1431
|
+
position: absolute;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.sphere-item {
|
|
1435
|
+
width: calc(var(--item-width) * var(--item-size-x));
|
|
1436
|
+
height: calc(var(--item-height) * var(--item-size-y));
|
|
1437
|
+
position: absolute;
|
|
1438
|
+
top: -999px;
|
|
1439
|
+
bottom: -999px;
|
|
1440
|
+
left: -999px;
|
|
1441
|
+
right: -999px;
|
|
1442
|
+
margin: auto;
|
|
1443
|
+
transform-origin: 50% 50%;
|
|
1444
|
+
backface-visibility: hidden;
|
|
1445
|
+
transition: transform 300ms;
|
|
1446
|
+
transform: rotateY(calc(var(--rot-y) * (var(--offset-x) + ((var(--item-size-x) - 1) / 2)) + var(--rot-y-delta, 0deg)))
|
|
1447
|
+
rotateX(calc(var(--rot-x) * (var(--offset-y) - ((var(--item-size-y) - 1) / 2)) + var(--rot-x-delta, 0deg)))
|
|
1448
|
+
translateZ(var(--radius));
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
.sphere-root[data-enlarging="true"] .scrim {
|
|
1452
|
+
opacity: 1 !important;
|
|
1453
|
+
pointer-events: all !important;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
@media (max-aspect-ratio: 1/1) {
|
|
1457
|
+
.viewer-frame {
|
|
1458
|
+
height: auto !important;
|
|
1459
|
+
width: 100% !important;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
.item__image {
|
|
1464
|
+
position: absolute;
|
|
1465
|
+
inset: 10px;
|
|
1466
|
+
border-radius: var(--tile-radius, 12px);
|
|
1467
|
+
overflow: hidden;
|
|
1468
|
+
cursor: pointer;
|
|
1469
|
+
backface-visibility: hidden;
|
|
1470
|
+
-webkit-backface-visibility: hidden;
|
|
1471
|
+
transition: transform 300ms;
|
|
1472
|
+
pointer-events: auto;
|
|
1473
|
+
-webkit-transform: translateZ(0);
|
|
1474
|
+
transform: translateZ(0);
|
|
1475
|
+
}
|
|
1476
|
+
.item__image--reference {
|
|
1477
|
+
position: absolute;
|
|
1478
|
+
inset: 10px;
|
|
1479
|
+
pointer-events: none;
|
|
1480
|
+
}
|
|
1481
|
+
\`;
|
|
1482
|
+
|
|
1483
|
+
return (
|
|
1484
|
+
<>
|
|
1485
|
+
<style dangerouslySetInnerHTML={{ __html: cssStyles }} />
|
|
1486
|
+
<div
|
|
1487
|
+
ref={rootRef}
|
|
1488
|
+
className="sphere-root relative w-full h-full"
|
|
1489
|
+
style={
|
|
1490
|
+
{
|
|
1491
|
+
['--segments-x' as string]: segments,
|
|
1492
|
+
['--segments-y' as string]: segments,
|
|
1493
|
+
['--overlay-blur-color' as string]: overlayBlurColor,
|
|
1494
|
+
['--tile-radius' as string]: imageBorderRadius,
|
|
1495
|
+
['--enlarge-radius' as string]: openedImageBorderRadius,
|
|
1496
|
+
['--image-filter' as string]: grayscale ? 'grayscale(1)' : 'none'
|
|
1497
|
+
} as React.CSSProperties
|
|
1498
|
+
}
|
|
1499
|
+
>
|
|
1500
|
+
<main
|
|
1501
|
+
ref={mainRef}
|
|
1502
|
+
className="absolute inset-0 grid place-items-center overflow-hidden select-none bg-transparent"
|
|
1503
|
+
style={{
|
|
1504
|
+
touchAction: 'none',
|
|
1505
|
+
WebkitUserSelect: 'none'
|
|
1506
|
+
}}
|
|
1507
|
+
>
|
|
1508
|
+
<div className="stage">
|
|
1509
|
+
<div ref={sphereRef} className="sphere">
|
|
1510
|
+
{items.map((it, i) => (
|
|
1511
|
+
<div
|
|
1512
|
+
key={\`\${it.x},\${it.y},\${i}\`}
|
|
1513
|
+
className="sphere-item absolute m-auto"
|
|
1514
|
+
data-src={it.src}
|
|
1515
|
+
data-alt={it.alt}
|
|
1516
|
+
data-offset-x={it.x}
|
|
1517
|
+
data-offset-y={it.y}
|
|
1518
|
+
data-size-x={it.sizeX}
|
|
1519
|
+
data-size-y={it.sizeY}
|
|
1520
|
+
style={
|
|
1521
|
+
{
|
|
1522
|
+
['--offset-x' as string]: it.x,
|
|
1523
|
+
['--offset-y' as string]: it.y,
|
|
1524
|
+
['--item-size-x' as string]: it.sizeX,
|
|
1525
|
+
['--item-size-y' as string]: it.sizeY,
|
|
1526
|
+
top: '-999px',
|
|
1527
|
+
bottom: '-999px',
|
|
1528
|
+
left: '-999px',
|
|
1529
|
+
right: '-999px'
|
|
1530
|
+
} as React.CSSProperties
|
|
1531
|
+
}
|
|
1532
|
+
>
|
|
1533
|
+
<div
|
|
1534
|
+
className={\`item__image absolute block overflow-hidden bg-gray-200 transition-transform duration-300 \${disableInteraction ? 'pointer-events-none' : 'cursor-pointer'}\`}
|
|
1535
|
+
role={disableInteraction ? undefined : "button"}
|
|
1536
|
+
tabIndex={disableInteraction ? -1 : 0}
|
|
1537
|
+
aria-label={disableInteraction ? undefined : (it.alt || 'Open image')}
|
|
1538
|
+
onClick={disableInteraction ? undefined : (e => {
|
|
1539
|
+
if (draggingRef.current) return;
|
|
1540
|
+
if (movedRef.current) return;
|
|
1541
|
+
if (performance.now() - lastDragEndAt.current < 80) return;
|
|
1542
|
+
if (openingRef.current) return;
|
|
1543
|
+
openItemFromElement(e.currentTarget as HTMLElement);
|
|
1544
|
+
})}
|
|
1545
|
+
onPointerUp={disableInteraction ? undefined : (e => {
|
|
1546
|
+
if ((e.nativeEvent as PointerEvent).pointerType !== 'touch') return;
|
|
1547
|
+
if (draggingRef.current) return;
|
|
1548
|
+
if (movedRef.current) return;
|
|
1549
|
+
if (performance.now() - lastDragEndAt.current < 80) return;
|
|
1550
|
+
if (openingRef.current) return;
|
|
1551
|
+
openItemFromElement(e.currentTarget as HTMLElement);
|
|
1552
|
+
})}
|
|
1553
|
+
style={{
|
|
1554
|
+
inset: '10px',
|
|
1555
|
+
borderRadius: \`var(--tile-radius, \${imageBorderRadius})\`,
|
|
1556
|
+
backfaceVisibility: 'hidden'
|
|
1557
|
+
}}
|
|
1558
|
+
>
|
|
1559
|
+
<img
|
|
1560
|
+
src={it.src}
|
|
1561
|
+
draggable={false}
|
|
1562
|
+
alt={it.alt}
|
|
1563
|
+
className="w-full h-full object-cover pointer-events-none"
|
|
1564
|
+
style={{
|
|
1565
|
+
backfaceVisibility: 'hidden',
|
|
1566
|
+
filter: \`var(--image-filter, \${grayscale ? 'grayscale(1)' : 'none'})\`
|
|
1567
|
+
}}
|
|
1568
|
+
/>
|
|
1569
|
+
</div>
|
|
1570
|
+
</div>
|
|
1571
|
+
))}
|
|
1572
|
+
</div>
|
|
1573
|
+
</div>
|
|
1574
|
+
|
|
1575
|
+
<div
|
|
1576
|
+
className="absolute inset-0 m-auto z-[3] pointer-events-none"
|
|
1577
|
+
style={{
|
|
1578
|
+
backgroundImage: \`radial-gradient(rgba(235, 235, 235, 0) 65%, var(--overlay-blur-color, \${overlayBlurColor}) 100%)\`
|
|
1579
|
+
}}
|
|
1580
|
+
/>
|
|
1581
|
+
|
|
1582
|
+
<div
|
|
1583
|
+
className="absolute inset-0 m-auto z-[3] pointer-events-none"
|
|
1584
|
+
style={{
|
|
1585
|
+
WebkitMaskImage: \`radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, \${overlayBlurColor}) 90%)\`,
|
|
1586
|
+
maskImage: \`radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, \${overlayBlurColor}) 90%)\`,
|
|
1587
|
+
backdropFilter: 'blur(3px)'
|
|
1588
|
+
}}
|
|
1589
|
+
/>
|
|
1590
|
+
|
|
1591
|
+
<div
|
|
1592
|
+
className="absolute left-0 right-0 top-0 h-[120px] z-[5] pointer-events-none rotate-180"
|
|
1593
|
+
style={{
|
|
1594
|
+
background: \`linear-gradient(to bottom, transparent, var(--overlay-blur-color, \${overlayBlurColor}))\`
|
|
1595
|
+
}}
|
|
1596
|
+
/>
|
|
1597
|
+
<div
|
|
1598
|
+
className="absolute left-0 right-0 bottom-0 h-[120px] z-[5] pointer-events-none"
|
|
1599
|
+
style={{
|
|
1600
|
+
background: \`linear-gradient(to bottom, transparent, var(--overlay-blur-color, \${overlayBlurColor}))\`
|
|
1601
|
+
}}
|
|
1602
|
+
/>
|
|
1603
|
+
|
|
1604
|
+
<div
|
|
1605
|
+
ref={viewerRef}
|
|
1606
|
+
className="absolute inset-0 z-20 pointer-events-none flex items-center justify-center"
|
|
1607
|
+
style={{ padding: 'var(--viewer-pad)' }}
|
|
1608
|
+
>
|
|
1609
|
+
<div
|
|
1610
|
+
ref={scrimRef}
|
|
1611
|
+
className="scrim absolute inset-0 z-10 pointer-events-none opacity-0 transition-opacity duration-500"
|
|
1612
|
+
style={{
|
|
1613
|
+
background: 'rgba(0, 0, 0, 0.4)',
|
|
1614
|
+
backdropFilter: 'blur(3px)'
|
|
1615
|
+
}}
|
|
1616
|
+
/>
|
|
1617
|
+
<div
|
|
1618
|
+
ref={frameRef}
|
|
1619
|
+
className="viewer-frame h-full aspect-square flex"
|
|
1620
|
+
style={{
|
|
1621
|
+
borderRadius: \`var(--enlarge-radius, \${openedImageBorderRadius})\`
|
|
1622
|
+
}}
|
|
1623
|
+
/>
|
|
1624
|
+
</div>
|
|
1625
|
+
</main>
|
|
1626
|
+
</div>
|
|
1627
|
+
</>
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
`,
|
|
1631
|
+
"components/3d/flip-card": `"use client";
|
|
1632
|
+
|
|
1633
|
+
import { useEffect, useState, useCallback } from "react";
|
|
1634
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
1635
|
+
import { CardContainer, CardBody, CardItem } from "__COMPONENTS_ALIAS__/3d-card";
|
|
1636
|
+
|
|
1637
|
+
interface FlipCardProps {
|
|
1638
|
+
isOpen: boolean;
|
|
1639
|
+
onClose: () => void;
|
|
1640
|
+
title: string;
|
|
1641
|
+
subtitle?: string;
|
|
1642
|
+
image?: string;
|
|
1643
|
+
badges?: Array<{ label: string; color?: string }>;
|
|
1644
|
+
quote?: string;
|
|
1645
|
+
children?: React.ReactNode;
|
|
1646
|
+
className?: string;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export function FlipCard({ isOpen, onClose, title, subtitle, image, badges, quote, children, className }: FlipCardProps) {
|
|
1650
|
+
const [isExiting, setIsExiting] = useState(false);
|
|
1651
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
1652
|
+
const [isFlipped, setIsFlipped] = useState(false);
|
|
1653
|
+
|
|
1654
|
+
useEffect(() => {
|
|
1655
|
+
if (isOpen) {
|
|
1656
|
+
setIsVisible(true);
|
|
1657
|
+
setIsExiting(false);
|
|
1658
|
+
setIsFlipped(false);
|
|
1659
|
+
document.body.style.overflow = "hidden";
|
|
1660
|
+
const flipTimer = setTimeout(() => setIsFlipped(true), 50);
|
|
1661
|
+
return () => clearTimeout(flipTimer);
|
|
1662
|
+
} else if (!isOpen && isVisible) {
|
|
1663
|
+
setIsExiting(true);
|
|
1664
|
+
setIsFlipped(false);
|
|
1665
|
+
const timer = setTimeout(() => {
|
|
1666
|
+
setIsVisible(false);
|
|
1667
|
+
setIsExiting(false);
|
|
1668
|
+
document.body.style.overflow = "";
|
|
1669
|
+
}, 400);
|
|
1670
|
+
return () => clearTimeout(timer);
|
|
1671
|
+
}
|
|
1672
|
+
}, [isOpen, isVisible]);
|
|
1673
|
+
|
|
1674
|
+
useEffect(() => {
|
|
1675
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1676
|
+
if (e.key === "Escape" && isOpen) onClose();
|
|
1677
|
+
};
|
|
1678
|
+
if (isOpen) {
|
|
1679
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
1680
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
1681
|
+
}
|
|
1682
|
+
}, [isOpen, onClose]);
|
|
1683
|
+
|
|
1684
|
+
const handleOverlayClick = useCallback(
|
|
1685
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
1686
|
+
if (e.target === e.currentTarget) onClose();
|
|
1687
|
+
},
|
|
1688
|
+
[onClose]
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
if (!isVisible) return null;
|
|
1692
|
+
|
|
1693
|
+
return (
|
|
1694
|
+
<div
|
|
1695
|
+
className={cn(
|
|
1696
|
+
"fixed inset-0 z-50 flex items-center justify-center",
|
|
1697
|
+
"bg-black/80 backdrop-blur-sm",
|
|
1698
|
+
"transition-opacity duration-300",
|
|
1699
|
+
isExiting ? "opacity-0" : "opacity-100"
|
|
1700
|
+
)}
|
|
1701
|
+
onClick={handleOverlayClick}
|
|
1702
|
+
role="dialog"
|
|
1703
|
+
aria-modal="true"
|
|
1704
|
+
style={{ perspective: "1200px" }}
|
|
1705
|
+
>
|
|
1706
|
+
<div
|
|
1707
|
+
className="transition-transform duration-500 ease-out"
|
|
1708
|
+
style={{
|
|
1709
|
+
transformStyle: "preserve-3d",
|
|
1710
|
+
transform: isFlipped
|
|
1711
|
+
? "rotateY(0deg)"
|
|
1712
|
+
: isExiting
|
|
1713
|
+
? "rotateY(-90deg)"
|
|
1714
|
+
: "rotateY(180deg)",
|
|
1715
|
+
touchAction: "none",
|
|
1716
|
+
}}
|
|
1717
|
+
>
|
|
1718
|
+
<CardContainer
|
|
1719
|
+
containerClassName={cn("!py-0", "transition-opacity duration-300", isExiting ? "opacity-0" : "opacity-100")}
|
|
1720
|
+
>
|
|
1721
|
+
<CardBody className={cn(
|
|
1722
|
+
"relative group/card w-[320px] sm:w-[380px] h-auto rounded-2xl p-6 sm:p-8 border border-white/20 bg-gradient-to-br from-white/10 to-white/5 backdrop-blur-xl shadow-2xl",
|
|
1723
|
+
className
|
|
1724
|
+
)}>
|
|
1725
|
+
<CardItem translateZ={40} className="absolute top-3 right-3 z-10">
|
|
1726
|
+
<button onClick={onClose} className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors" aria-label="Close">
|
|
1727
|
+
<svg className="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1728
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
1729
|
+
</svg>
|
|
1730
|
+
</button>
|
|
1731
|
+
</CardItem>
|
|
1732
|
+
|
|
1733
|
+
{image && (
|
|
1734
|
+
<CardItem translateZ={100} className="w-full flex justify-center mb-4">
|
|
1735
|
+
<div
|
|
1736
|
+
className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-cover bg-center border-2 border-white/20 shadow-lg"
|
|
1737
|
+
style={{ backgroundImage: \`url(\${image})\` }}
|
|
1738
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
1739
|
+
/>
|
|
1740
|
+
</CardItem>
|
|
1741
|
+
)}
|
|
1742
|
+
|
|
1743
|
+
<CardItem translateZ={60} className="w-full text-center">
|
|
1744
|
+
<h3 className="text-xl sm:text-2xl font-bold text-white mb-2">{title}</h3>
|
|
1745
|
+
</CardItem>
|
|
1746
|
+
|
|
1747
|
+
{badges && badges.length > 0 && (
|
|
1748
|
+
<CardItem translateZ={50} className="w-full flex flex-wrap justify-center gap-2 mb-3">
|
|
1749
|
+
{badges.map((badge, i) => (
|
|
1750
|
+
<span key={i} className="px-3 py-1 rounded-full text-xs font-medium border"
|
|
1751
|
+
style={{
|
|
1752
|
+
backgroundColor: \`\${badge.color || '#00E5A0'}20\`,
|
|
1753
|
+
color: badge.color || '#00E5A0',
|
|
1754
|
+
borderColor: \`\${badge.color || '#00E5A0'}30\`,
|
|
1755
|
+
}}>
|
|
1756
|
+
{badge.label}
|
|
1757
|
+
</span>
|
|
1758
|
+
))}
|
|
1759
|
+
</CardItem>
|
|
1760
|
+
)}
|
|
1761
|
+
|
|
1762
|
+
{subtitle && (
|
|
1763
|
+
<CardItem translateZ={40} className="w-full flex justify-center items-center gap-3 mb-6">
|
|
1764
|
+
<p className="text-sm font-medium text-white/90 text-center">{subtitle}</p>
|
|
1765
|
+
</CardItem>
|
|
1766
|
+
)}
|
|
1767
|
+
|
|
1768
|
+
<CardItem translateZ={20} className="w-full mb-6">
|
|
1769
|
+
<div className="w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
|
1770
|
+
</CardItem>
|
|
1771
|
+
|
|
1772
|
+
{quote && (
|
|
1773
|
+
<CardItem translateZ={80} className="w-full">
|
|
1774
|
+
<div className="relative">
|
|
1775
|
+
<svg className="absolute -top-2 -left-1 w-8 h-8 text-[#00E5A0]/30" fill="currentColor" viewBox="0 0 24 24">
|
|
1776
|
+
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
|
1777
|
+
</svg>
|
|
1778
|
+
<p className="text-sm sm:text-base text-white/80 leading-relaxed pl-6 italic">{quote}</p>
|
|
1779
|
+
</div>
|
|
1780
|
+
</CardItem>
|
|
1781
|
+
)}
|
|
1782
|
+
|
|
1783
|
+
{children}
|
|
1784
|
+
</CardBody>
|
|
1785
|
+
</CardContainer>
|
|
1786
|
+
</div>
|
|
1787
|
+
</div>
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
`,
|
|
1791
|
+
"components/3d/glass-surface": `"use client";
|
|
1792
|
+
|
|
1793
|
+
import React, { useEffect, useRef, useState, useId, useCallback } from "react";
|
|
1794
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
1795
|
+
|
|
1796
|
+
export interface GlassSurfaceProps {
|
|
1797
|
+
children?: React.ReactNode;
|
|
1798
|
+
width?: number | string;
|
|
1799
|
+
height?: number | string;
|
|
1800
|
+
borderRadius?: number;
|
|
1801
|
+
borderWidth?: number;
|
|
1802
|
+
brightness?: number;
|
|
1803
|
+
opacity?: number;
|
|
1804
|
+
blur?: number;
|
|
1805
|
+
displace?: number;
|
|
1806
|
+
backgroundOpacity?: number;
|
|
1807
|
+
saturation?: number;
|
|
1808
|
+
distortionScale?: number;
|
|
1809
|
+
redOffset?: number;
|
|
1810
|
+
greenOffset?: number;
|
|
1811
|
+
blueOffset?: number;
|
|
1812
|
+
xChannel?: "R" | "G" | "B";
|
|
1813
|
+
yChannel?: "R" | "G" | "B";
|
|
1814
|
+
mixBlendMode?:
|
|
1815
|
+
| "normal"
|
|
1816
|
+
| "multiply"
|
|
1817
|
+
| "screen"
|
|
1818
|
+
| "overlay"
|
|
1819
|
+
| "darken"
|
|
1820
|
+
| "lighten"
|
|
1821
|
+
| "color-dodge"
|
|
1822
|
+
| "color-burn"
|
|
1823
|
+
| "hard-light"
|
|
1824
|
+
| "soft-light"
|
|
1825
|
+
| "difference"
|
|
1826
|
+
| "exclusion"
|
|
1827
|
+
| "hue"
|
|
1828
|
+
| "saturation"
|
|
1829
|
+
| "color"
|
|
1830
|
+
| "luminosity"
|
|
1831
|
+
| "plus-darker"
|
|
1832
|
+
| "plus-lighter";
|
|
1833
|
+
className?: string;
|
|
1834
|
+
style?: React.CSSProperties;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const GlassSurface: React.FC<GlassSurfaceProps> = ({
|
|
1838
|
+
children,
|
|
1839
|
+
width = "auto",
|
|
1840
|
+
height = "auto",
|
|
1841
|
+
borderRadius = 20,
|
|
1842
|
+
borderWidth = 0.07,
|
|
1843
|
+
brightness = 50,
|
|
1844
|
+
opacity = 0.93,
|
|
1845
|
+
blur = 11,
|
|
1846
|
+
displace = 0,
|
|
1847
|
+
backgroundOpacity = 0.08,
|
|
1848
|
+
saturation = 1,
|
|
1849
|
+
distortionScale = -180,
|
|
1850
|
+
redOffset = 0,
|
|
1851
|
+
greenOffset = 10,
|
|
1852
|
+
blueOffset = 20,
|
|
1853
|
+
xChannel = "R",
|
|
1854
|
+
yChannel = "G",
|
|
1855
|
+
mixBlendMode = "difference",
|
|
1856
|
+
className = "",
|
|
1857
|
+
style = {},
|
|
1858
|
+
}) => {
|
|
1859
|
+
const uniqueId = useId().replace(/:/g, "-");
|
|
1860
|
+
const filterId = \`glass-filter-\${uniqueId}\`;
|
|
1861
|
+
const redGradId = \`red-grad-\${uniqueId}\`;
|
|
1862
|
+
const blueGradId = \`blue-grad-\${uniqueId}\`;
|
|
1863
|
+
|
|
1864
|
+
const [svgSupported, setSvgSupported] = useState<boolean>(false);
|
|
1865
|
+
|
|
1866
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1867
|
+
const feImageRef = useRef<SVGFEImageElement>(null);
|
|
1868
|
+
const redChannelRef = useRef<SVGFEDisplacementMapElement>(null);
|
|
1869
|
+
const greenChannelRef = useRef<SVGFEDisplacementMapElement>(null);
|
|
1870
|
+
const blueChannelRef = useRef<SVGFEDisplacementMapElement>(null);
|
|
1871
|
+
const gaussianBlurRef = useRef<SVGFEGaussianBlurElement>(null);
|
|
1872
|
+
|
|
1873
|
+
const generateDisplacementMap = () => {
|
|
1874
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
1875
|
+
const actualWidth = rect?.width || 400;
|
|
1876
|
+
const actualHeight = rect?.height || 200;
|
|
1877
|
+
const edgeSize = Math.min(actualWidth, actualHeight) * (borderWidth * 0.5);
|
|
1878
|
+
|
|
1879
|
+
const svgContent = \`
|
|
1880
|
+
<svg viewBox="0 0 \${actualWidth} \${actualHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
1881
|
+
<defs>
|
|
1882
|
+
<linearGradient id="\${redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
|
|
1883
|
+
<stop offset="0%" stop-color="#0000"/>
|
|
1884
|
+
<stop offset="100%" stop-color="red"/>
|
|
1885
|
+
</linearGradient>
|
|
1886
|
+
<linearGradient id="\${blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
1887
|
+
<stop offset="0%" stop-color="#0000"/>
|
|
1888
|
+
<stop offset="100%" stop-color="blue"/>
|
|
1889
|
+
</linearGradient>
|
|
1890
|
+
</defs>
|
|
1891
|
+
<rect x="0" y="0" width="\${actualWidth}" height="\${actualHeight}" fill="black"></rect>
|
|
1892
|
+
<rect x="0" y="0" width="\${actualWidth}" height="\${actualHeight}" rx="\${borderRadius}" fill="url(#\${redGradId})" />
|
|
1893
|
+
<rect x="0" y="0" width="\${actualWidth}" height="\${actualHeight}" rx="\${borderRadius}" fill="url(#\${blueGradId})" style="mix-blend-mode: \${mixBlendMode}" />
|
|
1894
|
+
<rect x="\${edgeSize}" y="\${edgeSize}" width="\${actualWidth - edgeSize * 2}" height="\${actualHeight - edgeSize * 2}" rx="\${borderRadius}" fill="hsl(0 0% \${brightness}% / \${opacity})" style="filter:blur(\${blur}px)" />
|
|
1895
|
+
</svg>
|
|
1896
|
+
\`;
|
|
1897
|
+
|
|
1898
|
+
return \`data:image/svg+xml,\${encodeURIComponent(svgContent)}\`;
|
|
1899
|
+
};
|
|
1900
|
+
|
|
1901
|
+
const updateDisplacementMap = () => {
|
|
1902
|
+
feImageRef.current?.setAttribute("href", generateDisplacementMap());
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
useEffect(() => {
|
|
1906
|
+
updateDisplacementMap();
|
|
1907
|
+
[
|
|
1908
|
+
{ ref: redChannelRef, offset: redOffset },
|
|
1909
|
+
{ ref: greenChannelRef, offset: greenOffset },
|
|
1910
|
+
{ ref: blueChannelRef, offset: blueOffset },
|
|
1911
|
+
].forEach(({ ref, offset }) => {
|
|
1912
|
+
if (ref.current) {
|
|
1913
|
+
ref.current.setAttribute("scale", (distortionScale + offset).toString());
|
|
1914
|
+
ref.current.setAttribute("xChannelSelector", xChannel);
|
|
1915
|
+
ref.current.setAttribute("yChannelSelector", yChannel);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
gaussianBlurRef.current?.setAttribute("stdDeviation", displace.toString());
|
|
1920
|
+
}, [
|
|
1921
|
+
width,
|
|
1922
|
+
height,
|
|
1923
|
+
borderRadius,
|
|
1924
|
+
borderWidth,
|
|
1925
|
+
brightness,
|
|
1926
|
+
opacity,
|
|
1927
|
+
blur,
|
|
1928
|
+
displace,
|
|
1929
|
+
distortionScale,
|
|
1930
|
+
redOffset,
|
|
1931
|
+
greenOffset,
|
|
1932
|
+
blueOffset,
|
|
1933
|
+
xChannel,
|
|
1934
|
+
yChannel,
|
|
1935
|
+
mixBlendMode,
|
|
1936
|
+
]);
|
|
1937
|
+
|
|
1938
|
+
useEffect(() => {
|
|
1939
|
+
setSvgSupported(supportsSVGFilters());
|
|
1940
|
+
}, []);
|
|
1941
|
+
|
|
1942
|
+
useEffect(() => {
|
|
1943
|
+
if (!containerRef.current) return;
|
|
1944
|
+
|
|
1945
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1946
|
+
|
|
1947
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1948
|
+
// Debounce resize updates (150ms)
|
|
1949
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1950
|
+
debounceTimer = setTimeout(updateDisplacementMap, 150);
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
resizeObserver.observe(containerRef.current);
|
|
1954
|
+
|
|
1955
|
+
return () => {
|
|
1956
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1957
|
+
resizeObserver.disconnect();
|
|
1958
|
+
};
|
|
1959
|
+
}, []);
|
|
1960
|
+
|
|
1961
|
+
useEffect(() => {
|
|
1962
|
+
setTimeout(updateDisplacementMap, 0);
|
|
1963
|
+
}, [width, height]);
|
|
1964
|
+
|
|
1965
|
+
const supportsSVGFilters = () => {
|
|
1966
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1967
|
+
return false;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
const isWebkit =
|
|
1971
|
+
/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
|
|
1972
|
+
const isFirefox = /Firefox/.test(navigator.userAgent);
|
|
1973
|
+
|
|
1974
|
+
if (isWebkit || isFirefox) {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const div = document.createElement("div");
|
|
1979
|
+
div.style.backdropFilter = \`url(#\${filterId})\`;
|
|
1980
|
+
|
|
1981
|
+
return div.style.backdropFilter !== "";
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
const supportsBackdropFilter = () => {
|
|
1985
|
+
if (typeof window === "undefined") return false;
|
|
1986
|
+
return CSS.supports("backdrop-filter", "blur(10px)");
|
|
1987
|
+
};
|
|
1988
|
+
|
|
1989
|
+
const getContainerStyles = (): React.CSSProperties => {
|
|
1990
|
+
const baseStyles: React.CSSProperties = {
|
|
1991
|
+
...style,
|
|
1992
|
+
width: typeof width === "number" ? \`\${width}px\` : width,
|
|
1993
|
+
height: typeof height === "number" ? \`\${height}px\` : height,
|
|
1994
|
+
borderRadius: \`\${borderRadius}px\`,
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
const backdropFilterSupported = supportsBackdropFilter();
|
|
1998
|
+
|
|
1999
|
+
if (svgSupported) {
|
|
2000
|
+
// Full glass effect with SVG filters (Chrome/Edge desktop)
|
|
2001
|
+
return {
|
|
2002
|
+
...baseStyles,
|
|
2003
|
+
background: \`hsl(0 0% 0% / \${backgroundOpacity})\`,
|
|
2004
|
+
backdropFilter: \`url(#\${filterId}) saturate(\${saturation})\`,
|
|
2005
|
+
boxShadow: \`0 0 2px 1px color-mix(in oklch, white, transparent 65%) inset,
|
|
2006
|
+
0 0 10px 4px color-mix(in oklch, white, transparent 85%) inset,
|
|
2007
|
+
0px 4px 16px rgba(17, 17, 26, 0.05),
|
|
2008
|
+
0px 8px 24px rgba(17, 17, 26, 0.05),
|
|
2009
|
+
0px 16px 56px rgba(17, 17, 26, 0.05),
|
|
2010
|
+
0px 4px 16px rgba(17, 17, 26, 0.05) inset,
|
|
2011
|
+
0px 8px 24px rgba(17, 17, 26, 0.05) inset,
|
|
2012
|
+
0px 16px 56px rgba(17, 17, 26, 0.05) inset\`,
|
|
2013
|
+
};
|
|
2014
|
+
} else {
|
|
2015
|
+
// Fallback for Safari, Firefox, mobile
|
|
2016
|
+
if (!backdropFilterSupported) {
|
|
2017
|
+
return {
|
|
2018
|
+
...baseStyles,
|
|
2019
|
+
background: "rgba(0, 0, 0, 0.4)",
|
|
2020
|
+
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
2021
|
+
boxShadow: \`inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
|
2022
|
+
inset 0 -1px 0 0 rgba(255, 255, 255, 0.05)\`,
|
|
2023
|
+
};
|
|
2024
|
+
} else {
|
|
2025
|
+
return {
|
|
2026
|
+
...baseStyles,
|
|
2027
|
+
background: \`rgba(255, 255, 255, \${backgroundOpacity})\`,
|
|
2028
|
+
backdropFilter: "blur(20px) saturate(1.8) brightness(1.1)",
|
|
2029
|
+
WebkitBackdropFilter: "blur(20px) saturate(1.8) brightness(1.1)",
|
|
2030
|
+
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
2031
|
+
boxShadow: \`inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
|
2032
|
+
inset 0 -1px 0 0 rgba(255, 255, 255, 0.05),
|
|
2033
|
+
0 4px 24px rgba(0, 0, 0, 0.1)\`,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
return (
|
|
2040
|
+
<div
|
|
2041
|
+
ref={containerRef}
|
|
2042
|
+
className={cn(
|
|
2043
|
+
"relative overflow-hidden transition-opacity duration-[260ms] ease-out",
|
|
2044
|
+
className
|
|
2045
|
+
)}
|
|
2046
|
+
style={getContainerStyles()}
|
|
2047
|
+
>
|
|
2048
|
+
<svg
|
|
2049
|
+
className="w-full h-full pointer-events-none absolute inset-0 opacity-0 -z-10"
|
|
2050
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
2051
|
+
>
|
|
2052
|
+
<defs>
|
|
2053
|
+
<filter
|
|
2054
|
+
id={filterId}
|
|
2055
|
+
colorInterpolationFilters="sRGB"
|
|
2056
|
+
x="0%"
|
|
2057
|
+
y="0%"
|
|
2058
|
+
width="100%"
|
|
2059
|
+
height="100%"
|
|
2060
|
+
>
|
|
2061
|
+
<feImage
|
|
2062
|
+
ref={feImageRef}
|
|
2063
|
+
x="0"
|
|
2064
|
+
y="0"
|
|
2065
|
+
width="100%"
|
|
2066
|
+
height="100%"
|
|
2067
|
+
preserveAspectRatio="none"
|
|
2068
|
+
result="map"
|
|
2069
|
+
/>
|
|
2070
|
+
|
|
2071
|
+
<feDisplacementMap
|
|
2072
|
+
ref={redChannelRef}
|
|
2073
|
+
in="SourceGraphic"
|
|
2074
|
+
in2="map"
|
|
2075
|
+
id="redchannel"
|
|
2076
|
+
result="dispRed"
|
|
2077
|
+
/>
|
|
2078
|
+
<feColorMatrix
|
|
2079
|
+
in="dispRed"
|
|
2080
|
+
type="matrix"
|
|
2081
|
+
values="1 0 0 0 0
|
|
2082
|
+
0 0 0 0 0
|
|
2083
|
+
0 0 0 0 0
|
|
2084
|
+
0 0 0 1 0"
|
|
2085
|
+
result="red"
|
|
2086
|
+
/>
|
|
2087
|
+
|
|
2088
|
+
<feDisplacementMap
|
|
2089
|
+
ref={greenChannelRef}
|
|
2090
|
+
in="SourceGraphic"
|
|
2091
|
+
in2="map"
|
|
2092
|
+
id="greenchannel"
|
|
2093
|
+
result="dispGreen"
|
|
2094
|
+
/>
|
|
2095
|
+
<feColorMatrix
|
|
2096
|
+
in="dispGreen"
|
|
2097
|
+
type="matrix"
|
|
2098
|
+
values="0 0 0 0 0
|
|
2099
|
+
0 1 0 0 0
|
|
2100
|
+
0 0 0 0 0
|
|
2101
|
+
0 0 0 1 0"
|
|
2102
|
+
result="green"
|
|
2103
|
+
/>
|
|
2104
|
+
|
|
2105
|
+
<feDisplacementMap
|
|
2106
|
+
ref={blueChannelRef}
|
|
2107
|
+
in="SourceGraphic"
|
|
2108
|
+
in2="map"
|
|
2109
|
+
id="bluechannel"
|
|
2110
|
+
result="dispBlue"
|
|
2111
|
+
/>
|
|
2112
|
+
<feColorMatrix
|
|
2113
|
+
in="dispBlue"
|
|
2114
|
+
type="matrix"
|
|
2115
|
+
values="0 0 0 0 0
|
|
2116
|
+
0 0 0 0 0
|
|
2117
|
+
0 0 1 0 0
|
|
2118
|
+
0 0 0 1 0"
|
|
2119
|
+
result="blue"
|
|
2120
|
+
/>
|
|
2121
|
+
|
|
2122
|
+
<feBlend in="red" in2="green" mode="screen" result="rg" />
|
|
2123
|
+
<feBlend in="rg" in2="blue" mode="screen" result="output" />
|
|
2124
|
+
<feGaussianBlur ref={gaussianBlurRef} in="output" stdDeviation="0.7" />
|
|
2125
|
+
</filter>
|
|
2126
|
+
</defs>
|
|
2127
|
+
</svg>
|
|
2128
|
+
|
|
2129
|
+
{children}
|
|
2130
|
+
</div>
|
|
2131
|
+
);
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
export { GlassSurface };
|
|
2135
|
+
`,
|
|
2136
|
+
"components/3d/holo-card": `"use client";
|
|
2137
|
+
|
|
2138
|
+
import { useEffect, useRef, useCallback, useState } from "react";
|
|
2139
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
2140
|
+
|
|
2141
|
+
export interface HoloCardItem {
|
|
2142
|
+
title: string;
|
|
2143
|
+
description?: string;
|
|
2144
|
+
thumbnail?: string;
|
|
2145
|
+
category?: string;
|
|
2146
|
+
categoryColor?: string;
|
|
2147
|
+
tags?: string[];
|
|
2148
|
+
badge?: string;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
interface HoloCardProps {
|
|
2152
|
+
item: HoloCardItem | null;
|
|
2153
|
+
isOpen: boolean;
|
|
2154
|
+
onClose: () => void;
|
|
2155
|
+
backLogo?: string;
|
|
2156
|
+
backSubtitle?: string;
|
|
2157
|
+
footer?: string;
|
|
2158
|
+
className?: string;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export function HoloCard({
|
|
2162
|
+
item,
|
|
2163
|
+
isOpen,
|
|
2164
|
+
onClose,
|
|
2165
|
+
backLogo = "SPARK",
|
|
2166
|
+
backSubtitle = "UI COMPONENT",
|
|
2167
|
+
footer,
|
|
2168
|
+
}: HoloCardProps) {
|
|
2169
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
2170
|
+
const rafRef = useRef<number | null>(null);
|
|
2171
|
+
const pendingUpdateRef = useRef<{
|
|
2172
|
+
mx: number; my: number; rx: number; ry: number; hyp: number;
|
|
2173
|
+
} | null>(null);
|
|
2174
|
+
const [isExiting, setIsExiting] = useState(false);
|
|
2175
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
2176
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
2177
|
+
|
|
2178
|
+
useEffect(() => {
|
|
2179
|
+
if (isOpen && item) {
|
|
2180
|
+
setIsVisible(true);
|
|
2181
|
+
setIsExiting(false);
|
|
2182
|
+
setIsAnimating(true);
|
|
2183
|
+
document.body.style.overflow = "hidden";
|
|
2184
|
+
const animTimer = setTimeout(() => setIsAnimating(false), 700);
|
|
2185
|
+
return () => clearTimeout(animTimer);
|
|
2186
|
+
} else if (!isOpen && isVisible) {
|
|
2187
|
+
setIsExiting(true);
|
|
2188
|
+
if (rafRef.current !== null) {
|
|
2189
|
+
cancelAnimationFrame(rafRef.current);
|
|
2190
|
+
rafRef.current = null;
|
|
2191
|
+
}
|
|
2192
|
+
const timer = setTimeout(() => {
|
|
2193
|
+
setIsVisible(false);
|
|
2194
|
+
setIsExiting(false);
|
|
2195
|
+
document.body.style.overflow = "";
|
|
2196
|
+
}, 600);
|
|
2197
|
+
return () => clearTimeout(timer);
|
|
2198
|
+
}
|
|
2199
|
+
return () => {
|
|
2200
|
+
if (rafRef.current !== null) {
|
|
2201
|
+
cancelAnimationFrame(rafRef.current);
|
|
2202
|
+
rafRef.current = null;
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
}, [isOpen, item, isVisible]);
|
|
2206
|
+
|
|
2207
|
+
useEffect(() => {
|
|
2208
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
2209
|
+
if (e.key === "Escape" && isOpen) onClose();
|
|
2210
|
+
};
|
|
2211
|
+
if (isOpen) {
|
|
2212
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
2213
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
2214
|
+
}
|
|
2215
|
+
}, [isOpen, onClose]);
|
|
2216
|
+
|
|
2217
|
+
const applyStyleUpdates = useCallback(() => {
|
|
2218
|
+
const card = cardRef.current;
|
|
2219
|
+
const update = pendingUpdateRef.current;
|
|
2220
|
+
if (!card || !update) return;
|
|
2221
|
+
card.style.setProperty("--mx", \`\${update.mx}%\`);
|
|
2222
|
+
card.style.setProperty("--my", \`\${update.my}%\`);
|
|
2223
|
+
card.style.setProperty("--posx", \`\${update.mx}%\`);
|
|
2224
|
+
card.style.setProperty("--posy", \`\${update.my}%\`);
|
|
2225
|
+
card.style.setProperty("--rx", \`\${update.ry}deg\`);
|
|
2226
|
+
card.style.setProperty("--ry", \`\${update.rx}deg\`);
|
|
2227
|
+
card.style.setProperty("--hyp", \`\${update.hyp}\`);
|
|
2228
|
+
card.style.setProperty("--o", "1");
|
|
2229
|
+
pendingUpdateRef.current = null;
|
|
2230
|
+
rafRef.current = null;
|
|
2231
|
+
}, []);
|
|
2232
|
+
|
|
2233
|
+
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
2234
|
+
const card = cardRef.current;
|
|
2235
|
+
if (!card) return;
|
|
2236
|
+
const rect = card.getBoundingClientRect();
|
|
2237
|
+
const x = e.clientX - rect.left;
|
|
2238
|
+
const y = e.clientY - rect.top;
|
|
2239
|
+
const mx = (x / rect.width) * 100;
|
|
2240
|
+
const my = (y / rect.height) * 100;
|
|
2241
|
+
const px = mx - 50;
|
|
2242
|
+
const py = my - 50;
|
|
2243
|
+
const hyp = Math.sqrt(px * px + py * py) / 50;
|
|
2244
|
+
const rx = (py / 50) * -15;
|
|
2245
|
+
const ry = (px / 50) * 15;
|
|
2246
|
+
pendingUpdateRef.current = { mx, my, rx, ry, hyp };
|
|
2247
|
+
if (rafRef.current === null) {
|
|
2248
|
+
rafRef.current = requestAnimationFrame(applyStyleUpdates);
|
|
2249
|
+
}
|
|
2250
|
+
}, [applyStyleUpdates]);
|
|
2251
|
+
|
|
2252
|
+
const handleMouseLeave = useCallback(() => {
|
|
2253
|
+
const card = cardRef.current;
|
|
2254
|
+
if (!card) return;
|
|
2255
|
+
card.style.setProperty("--mx", "50%");
|
|
2256
|
+
card.style.setProperty("--my", "50%");
|
|
2257
|
+
card.style.setProperty("--rx", "0deg");
|
|
2258
|
+
card.style.setProperty("--ry", "0deg");
|
|
2259
|
+
card.style.setProperty("--hyp", "0.5");
|
|
2260
|
+
}, []);
|
|
2261
|
+
|
|
2262
|
+
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
|
|
2263
|
+
const card = cardRef.current;
|
|
2264
|
+
if (!card || !e.touches[0]) return;
|
|
2265
|
+
const rect = card.getBoundingClientRect();
|
|
2266
|
+
const touch = e.touches[0];
|
|
2267
|
+
const x = touch.clientX - rect.left;
|
|
2268
|
+
const y = touch.clientY - rect.top;
|
|
2269
|
+
const mx = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
|
2270
|
+
const my = Math.max(0, Math.min(100, (y / rect.height) * 100));
|
|
2271
|
+
const px = mx - 50;
|
|
2272
|
+
const py = my - 50;
|
|
2273
|
+
const hyp = Math.sqrt(px * px + py * py) / 50;
|
|
2274
|
+
const rx = (py / 50) * -10;
|
|
2275
|
+
const ry = (px / 50) * 10;
|
|
2276
|
+
pendingUpdateRef.current = { mx, my, rx, ry, hyp };
|
|
2277
|
+
if (rafRef.current === null) {
|
|
2278
|
+
rafRef.current = requestAnimationFrame(applyStyleUpdates);
|
|
2279
|
+
}
|
|
2280
|
+
}, [applyStyleUpdates]);
|
|
2281
|
+
|
|
2282
|
+
const handleOverlayClick = useCallback(
|
|
2283
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
2284
|
+
if (e.target === e.currentTarget) onClose();
|
|
2285
|
+
},
|
|
2286
|
+
[onClose]
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
if (!isVisible || !item) return null;
|
|
2290
|
+
|
|
2291
|
+
const glowColor = item.categoryColor || "#69d1e9";
|
|
2292
|
+
|
|
2293
|
+
return (
|
|
2294
|
+
<div
|
|
2295
|
+
className={cn(
|
|
2296
|
+
"holo-card-overlay",
|
|
2297
|
+
isExiting ? "holo-card-overlay--exiting" : "holo-card-overlay--entering"
|
|
2298
|
+
)}
|
|
2299
|
+
onClick={handleOverlayClick}
|
|
2300
|
+
role="dialog"
|
|
2301
|
+
aria-modal="true"
|
|
2302
|
+
aria-labelledby="holo-card-title"
|
|
2303
|
+
>
|
|
2304
|
+
<div className="holo-card-container">
|
|
2305
|
+
<div
|
|
2306
|
+
ref={cardRef}
|
|
2307
|
+
className={cn(
|
|
2308
|
+
"holo-card",
|
|
2309
|
+
isExiting && "holo-card--exiting",
|
|
2310
|
+
isAnimating && !isExiting && "holo-card--entering"
|
|
2311
|
+
)}
|
|
2312
|
+
style={{ "--glow": glowColor } as React.CSSProperties}
|
|
2313
|
+
onMouseMove={handleMouseMove}
|
|
2314
|
+
onMouseLeave={handleMouseLeave}
|
|
2315
|
+
onTouchMove={handleTouchMove}
|
|
2316
|
+
onTouchEnd={handleMouseLeave}
|
|
2317
|
+
>
|
|
2318
|
+
<div className="holo-card__back">
|
|
2319
|
+
<div className="holo-card__back-content">
|
|
2320
|
+
<div className="holo-card__back-logo">{backLogo}</div>
|
|
2321
|
+
<div className="holo-card__back-subtitle">{backSubtitle}</div>
|
|
2322
|
+
</div>
|
|
2323
|
+
</div>
|
|
2324
|
+
<div className="holo-card__front">
|
|
2325
|
+
<div className="holo-card__shine" />
|
|
2326
|
+
<div className="holo-card__glare" />
|
|
2327
|
+
<div className="holo-card__inner-frame" />
|
|
2328
|
+
<div className="holo-card__content">
|
|
2329
|
+
{item.thumbnail && (
|
|
2330
|
+
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
|
2331
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
2332
|
+
<img src={item.thumbnail} alt="" className="w-64 h-64 object-contain blur-[60px] opacity-40 saturate-150" aria-hidden="true" />
|
|
2333
|
+
</div>
|
|
2334
|
+
</div>
|
|
2335
|
+
)}
|
|
2336
|
+
<div className="relative z-10 p-6 sm:p-8 h-full flex flex-col">
|
|
2337
|
+
<div className="flex items-center justify-between flex-shrink-0">
|
|
2338
|
+
{item.category && (
|
|
2339
|
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs sm:text-sm font-bold uppercase tracking-wide border"
|
|
2340
|
+
style={{ color: glowColor, borderColor: \`\${glowColor}30\`, backgroundColor: \`\${glowColor}20\` }}>
|
|
2341
|
+
<span className="relative flex h-2 w-2">
|
|
2342
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: glowColor }} />
|
|
2343
|
+
<span className="relative inline-flex rounded-full h-2 w-2" style={{ backgroundColor: glowColor }} />
|
|
2344
|
+
</span>
|
|
2345
|
+
{item.category}
|
|
2346
|
+
</span>
|
|
2347
|
+
)}
|
|
2348
|
+
{item.badge && (
|
|
2349
|
+
<span className="text-xs sm:text-sm font-mono text-white/70">{item.badge}</span>
|
|
2350
|
+
)}
|
|
2351
|
+
</div>
|
|
2352
|
+
<div className="flex-1 flex flex-col justify-center items-center py-4">
|
|
2353
|
+
{item.thumbnail && (
|
|
2354
|
+
<div className="relative flex-shrink-0 mb-3 flex justify-center">
|
|
2355
|
+
<div className="relative w-32 h-32 sm:w-36 sm:h-36 rounded-lg overflow-hidden flex items-center justify-center">
|
|
2356
|
+
<img src={item.thumbnail} alt={item.title} className="w-full h-full object-contain" />
|
|
2357
|
+
</div>
|
|
2358
|
+
</div>
|
|
2359
|
+
)}
|
|
2360
|
+
<div className="text-center mb-2">
|
|
2361
|
+
<h3 id="holo-card-title" className="text-lg sm:text-xl font-bold text-white leading-tight">{item.title}</h3>
|
|
2362
|
+
</div>
|
|
2363
|
+
{item.description && (
|
|
2364
|
+
<div className="mb-2">
|
|
2365
|
+
<p className="text-[11px] sm:text-xs text-white/60 leading-relaxed text-center">{item.description}</p>
|
|
2366
|
+
</div>
|
|
2367
|
+
)}
|
|
2368
|
+
{item.tags && item.tags.length > 0 && (
|
|
2369
|
+
<div className="flex flex-wrap justify-center gap-1.5">
|
|
2370
|
+
{item.tags.slice(0, 4).map((tag) => (
|
|
2371
|
+
<span key={tag} className="px-2 py-0.5 bg-white/10 rounded text-[10px] sm:text-xs text-white/70 border border-white/20">{tag}</span>
|
|
2372
|
+
))}
|
|
2373
|
+
{item.tags.length > 4 && (
|
|
2374
|
+
<span className="px-2 py-0.5 text-[10px] sm:text-xs text-white/50">+{item.tags.length - 4}</span>
|
|
2375
|
+
)}
|
|
2376
|
+
</div>
|
|
2377
|
+
)}
|
|
2378
|
+
</div>
|
|
2379
|
+
{footer && (
|
|
2380
|
+
<div className="pt-2 border-t border-white/20 text-center flex-shrink-0">
|
|
2381
|
+
<p className="text-[10px] sm:text-xs text-white/50 tracking-wider">{footer}</p>
|
|
2382
|
+
</div>
|
|
2383
|
+
)}
|
|
2384
|
+
</div>
|
|
2385
|
+
</div>
|
|
2386
|
+
</div>
|
|
2387
|
+
</div>
|
|
2388
|
+
</div>
|
|
2389
|
+
</div>
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
`,
|
|
2393
|
+
"components/3d/prismatic-burst": `"use client";
|
|
2394
|
+
|
|
2395
|
+
import React, { useEffect, useRef } from "react";
|
|
2396
|
+
import { Renderer, Program, Mesh, Triangle, Texture } from "ogl";
|
|
2397
|
+
|
|
2398
|
+
type Offset = { x?: number | string; y?: number | string };
|
|
2399
|
+
type AnimationType = "rotate" | "rotate3d" | "hover";
|
|
2400
|
+
|
|
2401
|
+
export type PrismaticBurstProps = {
|
|
2402
|
+
intensity?: number;
|
|
2403
|
+
speed?: number;
|
|
2404
|
+
animationType?: AnimationType;
|
|
2405
|
+
colors?: string[];
|
|
2406
|
+
distort?: number;
|
|
2407
|
+
paused?: boolean;
|
|
2408
|
+
offset?: Offset;
|
|
2409
|
+
hoverDampness?: number;
|
|
2410
|
+
rayCount?: number;
|
|
2411
|
+
mixBlendMode?: React.CSSProperties["mixBlendMode"] | "none";
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
const vertexShader = \`#version 300 es
|
|
2415
|
+
in vec2 position;
|
|
2416
|
+
in vec2 uv;
|
|
2417
|
+
out vec2 vUv;
|
|
2418
|
+
void main() {
|
|
2419
|
+
vUv = uv;
|
|
2420
|
+
gl_Position = vec4(position, 0.0, 1.0);
|
|
2421
|
+
}
|
|
2422
|
+
\`;
|
|
2423
|
+
|
|
2424
|
+
const fragmentShader = \`#version 300 es
|
|
2425
|
+
precision highp float;
|
|
2426
|
+
precision highp int;
|
|
2427
|
+
|
|
2428
|
+
out vec4 fragColor;
|
|
2429
|
+
|
|
2430
|
+
uniform vec2 uResolution;
|
|
2431
|
+
uniform float uTime;
|
|
2432
|
+
|
|
2433
|
+
uniform float uIntensity;
|
|
2434
|
+
uniform float uSpeed;
|
|
2435
|
+
uniform int uAnimType;
|
|
2436
|
+
uniform vec2 uMouse;
|
|
2437
|
+
uniform int uColorCount;
|
|
2438
|
+
uniform float uDistort;
|
|
2439
|
+
uniform vec2 uOffset;
|
|
2440
|
+
uniform sampler2D uGradient;
|
|
2441
|
+
uniform float uNoiseAmount;
|
|
2442
|
+
uniform int uRayCount;
|
|
2443
|
+
|
|
2444
|
+
float hash21(vec2 p){
|
|
2445
|
+
p = floor(p);
|
|
2446
|
+
float f = 52.9829189 * fract(dot(p, vec2(0.065, 0.005)));
|
|
2447
|
+
return fract(f);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
mat2 rot30(){ return mat2(0.8, -0.5, 0.5, 0.8); }
|
|
2451
|
+
|
|
2452
|
+
float layeredNoise(vec2 fragPx){
|
|
2453
|
+
vec2 p = mod(fragPx + vec2(uTime * 30.0, -uTime * 21.0), 1024.0);
|
|
2454
|
+
vec2 q = rot30() * p;
|
|
2455
|
+
float n = 0.0;
|
|
2456
|
+
n += 0.40 * hash21(q);
|
|
2457
|
+
n += 0.25 * hash21(q * 2.0 + 17.0);
|
|
2458
|
+
n += 0.20 * hash21(q * 4.0 + 47.0);
|
|
2459
|
+
n += 0.10 * hash21(q * 8.0 + 113.0);
|
|
2460
|
+
n += 0.05 * hash21(q * 16.0 + 191.0);
|
|
2461
|
+
return n;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
vec3 rayDir(vec2 frag, vec2 res, vec2 offset, float dist){
|
|
2465
|
+
float focal = res.y * max(dist, 1e-3);
|
|
2466
|
+
return normalize(vec3(2.0 * (frag - offset) - res, focal));
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
float edgeFade(vec2 frag, vec2 res, vec2 offset){
|
|
2470
|
+
vec2 toC = frag - 0.5 * res - offset;
|
|
2471
|
+
float r = length(toC) / (0.5 * min(res.x, res.y));
|
|
2472
|
+
float x = clamp(r, 0.0, 1.0);
|
|
2473
|
+
float q = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
|
2474
|
+
float s = q * 0.5;
|
|
2475
|
+
s = pow(s, 1.5);
|
|
2476
|
+
float tail = 1.0 - pow(1.0 - s, 2.0);
|
|
2477
|
+
s = mix(s, tail, 0.2);
|
|
2478
|
+
float dn = (layeredNoise(frag * 0.15) - 0.5) * 0.0015 * s;
|
|
2479
|
+
return clamp(s + dn, 0.0, 1.0);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
mat3 rotX(float a){ float c = cos(a), s = sin(a); return mat3(1.0,0.0,0.0, 0.0,c,-s, 0.0,s,c); }
|
|
2483
|
+
mat3 rotY(float a){ float c = cos(a), s = sin(a); return mat3(c,0.0,s, 0.0,1.0,0.0, -s,0.0,c); }
|
|
2484
|
+
mat3 rotZ(float a){ float c = cos(a), s = sin(a); return mat3(c,-s,0.0, s,c,0.0, 0.0,0.0,1.0); }
|
|
2485
|
+
|
|
2486
|
+
vec3 sampleGradient(float t){
|
|
2487
|
+
t = clamp(t, 0.0, 1.0);
|
|
2488
|
+
return texture(uGradient, vec2(t, 0.5)).rgb;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
vec2 rot2(vec2 v, float a){
|
|
2492
|
+
float s = sin(a), c = cos(a);
|
|
2493
|
+
return mat2(c, -s, s, c) * v;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
float bendAngle(vec3 q, float t){
|
|
2497
|
+
float a = 0.8 * sin(q.x * 0.55 + t * 0.6)
|
|
2498
|
+
+ 0.7 * sin(q.y * 0.50 - t * 0.5)
|
|
2499
|
+
+ 0.6 * sin(q.z * 0.60 + t * 0.7);
|
|
2500
|
+
return a;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
void main(){
|
|
2504
|
+
vec2 frag = gl_FragCoord.xy;
|
|
2505
|
+
float t = uTime * uSpeed;
|
|
2506
|
+
float jitterAmp = 0.1 * clamp(uNoiseAmount, 0.0, 1.0);
|
|
2507
|
+
vec3 dir = rayDir(frag, uResolution, uOffset, 1.0);
|
|
2508
|
+
float marchT = 0.0;
|
|
2509
|
+
vec3 col = vec3(0.0);
|
|
2510
|
+
float n = layeredNoise(frag);
|
|
2511
|
+
vec4 c = cos(t * 0.2 + vec4(0.0, 33.0, 11.0, 0.0));
|
|
2512
|
+
mat2 M2 = mat2(c.x, c.y, c.z, c.w);
|
|
2513
|
+
float amp = clamp(uDistort, 0.0, 50.0) * 0.15;
|
|
2514
|
+
|
|
2515
|
+
mat3 rot3dMat = mat3(1.0);
|
|
2516
|
+
if(uAnimType == 1){
|
|
2517
|
+
vec3 ang = vec3(t * 0.31, t * 0.21, t * 0.17);
|
|
2518
|
+
rot3dMat = rotZ(ang.z) * rotY(ang.y) * rotX(ang.x);
|
|
2519
|
+
}
|
|
2520
|
+
mat3 hoverMat = mat3(1.0);
|
|
2521
|
+
if(uAnimType == 2){
|
|
2522
|
+
vec2 m = uMouse * 2.0 - 1.0;
|
|
2523
|
+
vec3 ang = vec3(m.y * 0.6, m.x * 0.6, 0.0);
|
|
2524
|
+
hoverMat = rotY(ang.y) * rotX(ang.x);
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
for (int i = 0; i < 44; ++i) {
|
|
2528
|
+
vec3 P = marchT * dir;
|
|
2529
|
+
P.z -= 2.0;
|
|
2530
|
+
float rad = length(P);
|
|
2531
|
+
vec3 Pl = P * (10.0 / max(rad, 1e-6));
|
|
2532
|
+
|
|
2533
|
+
if(uAnimType == 0){
|
|
2534
|
+
Pl.xz *= M2;
|
|
2535
|
+
} else if(uAnimType == 1){
|
|
2536
|
+
Pl = rot3dMat * Pl;
|
|
2537
|
+
} else {
|
|
2538
|
+
Pl = hoverMat * Pl;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
float stepLen = min(rad - 0.3, n * jitterAmp) + 0.1;
|
|
2542
|
+
|
|
2543
|
+
float grow = smoothstep(0.35, 3.0, marchT);
|
|
2544
|
+
float a1 = amp * grow * bendAngle(Pl * 0.6, t);
|
|
2545
|
+
float a2 = 0.5 * amp * grow * bendAngle(Pl.zyx * 0.5 + 3.1, t * 0.9);
|
|
2546
|
+
vec3 Pb = Pl;
|
|
2547
|
+
Pb.xz = rot2(Pb.xz, a1);
|
|
2548
|
+
Pb.xy = rot2(Pb.xy, a2);
|
|
2549
|
+
|
|
2550
|
+
float rayPattern = smoothstep(
|
|
2551
|
+
0.5, 0.7,
|
|
2552
|
+
sin(Pb.x + cos(Pb.y) * cos(Pb.z)) *
|
|
2553
|
+
sin(Pb.z + sin(Pb.y) * cos(Pb.x + t))
|
|
2554
|
+
);
|
|
2555
|
+
|
|
2556
|
+
if (uRayCount > 0) {
|
|
2557
|
+
float ang = atan(Pb.y, Pb.x);
|
|
2558
|
+
float comb = 0.5 + 0.5 * cos(float(uRayCount) * ang);
|
|
2559
|
+
comb = pow(comb, 3.0);
|
|
2560
|
+
rayPattern *= smoothstep(0.15, 0.95, comb);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
vec3 spectralDefault = 1.0 + vec3(
|
|
2564
|
+
cos(marchT * 3.0 + 0.0),
|
|
2565
|
+
cos(marchT * 3.0 + 1.0),
|
|
2566
|
+
cos(marchT * 3.0 + 2.0)
|
|
2567
|
+
);
|
|
2568
|
+
|
|
2569
|
+
float saw = fract(marchT * 0.25);
|
|
2570
|
+
float tRay = saw * saw * (3.0 - 2.0 * saw);
|
|
2571
|
+
vec3 userGradient = 2.0 * sampleGradient(tRay);
|
|
2572
|
+
vec3 spectral = (uColorCount > 0) ? userGradient : spectralDefault;
|
|
2573
|
+
vec3 base = (0.05 / (0.4 + stepLen))
|
|
2574
|
+
* smoothstep(5.0, 0.0, rad)
|
|
2575
|
+
* spectral;
|
|
2576
|
+
|
|
2577
|
+
col += base * rayPattern;
|
|
2578
|
+
marchT += stepLen;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
col *= edgeFade(frag, uResolution, uOffset);
|
|
2582
|
+
col *= uIntensity;
|
|
2583
|
+
|
|
2584
|
+
fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
|
|
2585
|
+
}\`;
|
|
2586
|
+
|
|
2587
|
+
const hexToRgb01 = (hex: string): [number, number, number] => {
|
|
2588
|
+
let h = hex.trim();
|
|
2589
|
+
if (h.startsWith("#")) h = h.slice(1);
|
|
2590
|
+
if (h.length === 3) {
|
|
2591
|
+
const r = h[0],
|
|
2592
|
+
g = h[1],
|
|
2593
|
+
b = h[2];
|
|
2594
|
+
h = r + r + g + g + b + b;
|
|
2595
|
+
}
|
|
2596
|
+
const intVal = parseInt(h, 16);
|
|
2597
|
+
if (isNaN(intVal) || (h.length !== 6 && h.length !== 8)) return [1, 1, 1];
|
|
2598
|
+
const r = ((intVal >> 16) & 255) / 255;
|
|
2599
|
+
const g = ((intVal >> 8) & 255) / 255;
|
|
2600
|
+
const b = (intVal & 255) / 255;
|
|
2601
|
+
return [r, g, b];
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
const toPx = (v: number | string | undefined): number => {
|
|
2605
|
+
if (v == null) return 0;
|
|
2606
|
+
if (typeof v === "number") return v;
|
|
2607
|
+
const s = String(v).trim();
|
|
2608
|
+
const num = parseFloat(s.replace("px", ""));
|
|
2609
|
+
return isNaN(num) ? 0 : num;
|
|
2610
|
+
};
|
|
2611
|
+
|
|
2612
|
+
const PrismaticBurst = ({
|
|
2613
|
+
intensity = 2,
|
|
2614
|
+
speed = 0.5,
|
|
2615
|
+
animationType = "rotate3d",
|
|
2616
|
+
colors,
|
|
2617
|
+
distort = 0,
|
|
2618
|
+
paused = false,
|
|
2619
|
+
offset = { x: 0, y: 0 },
|
|
2620
|
+
hoverDampness = 0,
|
|
2621
|
+
rayCount,
|
|
2622
|
+
mixBlendMode = "lighten",
|
|
2623
|
+
}: PrismaticBurstProps) => {
|
|
2624
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2625
|
+
const programRef = useRef<Program | null>(null);
|
|
2626
|
+
const rendererRef = useRef<Renderer | null>(null);
|
|
2627
|
+
const mouseTargetRef = useRef<[number, number]>([0.5, 0.5]);
|
|
2628
|
+
const mouseSmoothRef = useRef<[number, number]>([0.5, 0.5]);
|
|
2629
|
+
const pausedRef = useRef<boolean>(paused);
|
|
2630
|
+
const gradTexRef = useRef<Texture | null>(null);
|
|
2631
|
+
const hoverDampRef = useRef<number>(hoverDampness);
|
|
2632
|
+
const isVisibleRef = useRef<boolean>(true);
|
|
2633
|
+
const isScrollingRef = useRef<boolean>(false);
|
|
2634
|
+
const meshRef = useRef<Mesh | null>(null);
|
|
2635
|
+
const triRef = useRef<Triangle | null>(null);
|
|
2636
|
+
|
|
2637
|
+
useEffect(() => {
|
|
2638
|
+
pausedRef.current = paused;
|
|
2639
|
+
}, [paused]);
|
|
2640
|
+
useEffect(() => {
|
|
2641
|
+
hoverDampRef.current = hoverDampness;
|
|
2642
|
+
}, [hoverDampness]);
|
|
2643
|
+
|
|
2644
|
+
useEffect(() => {
|
|
2645
|
+
const container = containerRef.current;
|
|
2646
|
+
if (!container) return;
|
|
2647
|
+
|
|
2648
|
+
// Respect user's motion preferences
|
|
2649
|
+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
2650
|
+
if (prefersReducedMotion) {
|
|
2651
|
+
// Skip WebGL rendering entirely for users who prefer reduced motion
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// Performance optimization: Use DPR of 1 for all devices
|
|
2656
|
+
// Background effects don't need high resolution
|
|
2657
|
+
const dpr = 1;
|
|
2658
|
+
const renderer = new Renderer({ dpr, alpha: false, antialias: false });
|
|
2659
|
+
rendererRef.current = renderer;
|
|
2660
|
+
|
|
2661
|
+
const gl = renderer.gl;
|
|
2662
|
+
gl.canvas.style.position = "absolute";
|
|
2663
|
+
gl.canvas.style.inset = "0";
|
|
2664
|
+
gl.canvas.style.width = "100%";
|
|
2665
|
+
gl.canvas.style.height = "100%";
|
|
2666
|
+
gl.canvas.style.mixBlendMode =
|
|
2667
|
+
mixBlendMode && mixBlendMode !== "none" ? mixBlendMode : "";
|
|
2668
|
+
container.appendChild(gl.canvas);
|
|
2669
|
+
|
|
2670
|
+
const white = new Uint8Array([255, 255, 255, 255]);
|
|
2671
|
+
const gradientTex = new Texture(gl, {
|
|
2672
|
+
image: white,
|
|
2673
|
+
width: 1,
|
|
2674
|
+
height: 1,
|
|
2675
|
+
generateMipmaps: false,
|
|
2676
|
+
flipY: false,
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
gradientTex.minFilter = gl.LINEAR;
|
|
2680
|
+
gradientTex.magFilter = gl.LINEAR;
|
|
2681
|
+
gradientTex.wrapS = gl.CLAMP_TO_EDGE;
|
|
2682
|
+
gradientTex.wrapT = gl.CLAMP_TO_EDGE;
|
|
2683
|
+
gradTexRef.current = gradientTex;
|
|
2684
|
+
|
|
2685
|
+
const program = new Program(gl, {
|
|
2686
|
+
vertex: vertexShader,
|
|
2687
|
+
fragment: fragmentShader,
|
|
2688
|
+
uniforms: {
|
|
2689
|
+
uResolution: { value: [1, 1] as [number, number] },
|
|
2690
|
+
uTime: { value: 0 },
|
|
2691
|
+
|
|
2692
|
+
uIntensity: { value: 1 },
|
|
2693
|
+
uSpeed: { value: 1 },
|
|
2694
|
+
uAnimType: { value: 0 },
|
|
2695
|
+
uMouse: { value: [0.5, 0.5] as [number, number] },
|
|
2696
|
+
uColorCount: { value: 0 },
|
|
2697
|
+
uDistort: { value: 0 },
|
|
2698
|
+
uOffset: { value: [0, 0] as [number, number] },
|
|
2699
|
+
uGradient: { value: gradientTex },
|
|
2700
|
+
uNoiseAmount: { value: 0.8 },
|
|
2701
|
+
uRayCount: { value: 0 },
|
|
2702
|
+
},
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
programRef.current = program;
|
|
2706
|
+
|
|
2707
|
+
const triangle = new Triangle(gl);
|
|
2708
|
+
const mesh = new Mesh(gl, { geometry: triangle, program });
|
|
2709
|
+
triRef.current = triangle;
|
|
2710
|
+
meshRef.current = mesh;
|
|
2711
|
+
|
|
2712
|
+
const resize = () => {
|
|
2713
|
+
const w = container.clientWidth || 1;
|
|
2714
|
+
const h = container.clientHeight || 1;
|
|
2715
|
+
renderer.setSize(w, h);
|
|
2716
|
+
program.uniforms.uResolution.value = [
|
|
2717
|
+
gl.drawingBufferWidth,
|
|
2718
|
+
gl.drawingBufferHeight,
|
|
2719
|
+
];
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
let ro: ResizeObserver | null = null;
|
|
2723
|
+
if ("ResizeObserver" in window) {
|
|
2724
|
+
ro = new ResizeObserver(resize);
|
|
2725
|
+
ro.observe(container);
|
|
2726
|
+
} else {
|
|
2727
|
+
(window as Window).addEventListener("resize", resize);
|
|
2728
|
+
}
|
|
2729
|
+
resize();
|
|
2730
|
+
|
|
2731
|
+
const onPointer = (e: PointerEvent) => {
|
|
2732
|
+
const rect = container.getBoundingClientRect();
|
|
2733
|
+
const x = (e.clientX - rect.left) / Math.max(rect.width, 1);
|
|
2734
|
+
const y = (e.clientY - rect.top) / Math.max(rect.height, 1);
|
|
2735
|
+
mouseTargetRef.current = [
|
|
2736
|
+
Math.min(Math.max(x, 0), 1),
|
|
2737
|
+
Math.min(Math.max(y, 0), 1),
|
|
2738
|
+
];
|
|
2739
|
+
};
|
|
2740
|
+
container.addEventListener("pointermove", onPointer, { passive: true });
|
|
2741
|
+
|
|
2742
|
+
let raf = 0;
|
|
2743
|
+
let last = performance.now();
|
|
2744
|
+
let accumTime = 0;
|
|
2745
|
+
let isRunning = false;
|
|
2746
|
+
|
|
2747
|
+
const update = (now: number) => {
|
|
2748
|
+
if (!isRunning) return;
|
|
2749
|
+
const dt = Math.max(0, now - last) * 0.001;
|
|
2750
|
+
last = now;
|
|
2751
|
+
if (!pausedRef.current) accumTime += dt;
|
|
2752
|
+
const tau = 0.02 + Math.max(0, Math.min(1, hoverDampRef.current)) * 0.5;
|
|
2753
|
+
const alpha = 1 - Math.exp(-dt / tau);
|
|
2754
|
+
const tgt = mouseTargetRef.current;
|
|
2755
|
+
const sm = mouseSmoothRef.current;
|
|
2756
|
+
sm[0] += (tgt[0] - sm[0]) * alpha;
|
|
2757
|
+
sm[1] += (tgt[1] - sm[1]) * alpha;
|
|
2758
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2759
|
+
program.uniforms.uMouse.value = sm as any;
|
|
2760
|
+
program.uniforms.uTime.value = accumTime;
|
|
2761
|
+
renderer.render({ scene: meshRef.current! });
|
|
2762
|
+
raf = requestAnimationFrame(update);
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
const startLoop = () => {
|
|
2766
|
+
if (isRunning) return;
|
|
2767
|
+
isRunning = true;
|
|
2768
|
+
last = performance.now();
|
|
2769
|
+
raf = requestAnimationFrame(update);
|
|
2770
|
+
};
|
|
2771
|
+
|
|
2772
|
+
const stopLoop = () => {
|
|
2773
|
+
isRunning = false;
|
|
2774
|
+
if (raf) {
|
|
2775
|
+
cancelAnimationFrame(raf);
|
|
2776
|
+
raf = 0;
|
|
2777
|
+
}
|
|
2778
|
+
};
|
|
2779
|
+
|
|
2780
|
+
// Start/stop based on visibility and scroll state
|
|
2781
|
+
const checkVisibility = () => {
|
|
2782
|
+
const shouldRun = isVisibleRef.current && !document.hidden && !isScrollingRef.current;
|
|
2783
|
+
if (shouldRun) {
|
|
2784
|
+
startLoop();
|
|
2785
|
+
} else {
|
|
2786
|
+
stopLoop();
|
|
2787
|
+
}
|
|
2788
|
+
};
|
|
2789
|
+
|
|
2790
|
+
// Pause during scroll for better performance
|
|
2791
|
+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
2792
|
+
const onScroll = () => {
|
|
2793
|
+
isScrollingRef.current = true;
|
|
2794
|
+
checkVisibility();
|
|
2795
|
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
2796
|
+
scrollTimeout = setTimeout(() => {
|
|
2797
|
+
isScrollingRef.current = false;
|
|
2798
|
+
checkVisibility();
|
|
2799
|
+
}, 150);
|
|
2800
|
+
};
|
|
2801
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
2802
|
+
|
|
2803
|
+
let io: IntersectionObserver | null = null;
|
|
2804
|
+
if ("IntersectionObserver" in window) {
|
|
2805
|
+
io = new IntersectionObserver(
|
|
2806
|
+
(entries) => {
|
|
2807
|
+
if (entries[0]) {
|
|
2808
|
+
isVisibleRef.current = entries[0].isIntersecting;
|
|
2809
|
+
checkVisibility();
|
|
2810
|
+
}
|
|
2811
|
+
},
|
|
2812
|
+
{ root: null, threshold: 0.01 }
|
|
2813
|
+
);
|
|
2814
|
+
io.observe(container);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const onVis = () => checkVisibility();
|
|
2818
|
+
document.addEventListener("visibilitychange", onVis);
|
|
2819
|
+
|
|
2820
|
+
// Start loop if initially visible
|
|
2821
|
+
checkVisibility();
|
|
2822
|
+
|
|
2823
|
+
return () => {
|
|
2824
|
+
stopLoop();
|
|
2825
|
+
container.removeEventListener("pointermove", onPointer);
|
|
2826
|
+
ro?.disconnect();
|
|
2827
|
+
if (!ro) window.removeEventListener("resize", resize);
|
|
2828
|
+
io?.disconnect();
|
|
2829
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
2830
|
+
window.removeEventListener('scroll', onScroll);
|
|
2831
|
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
2832
|
+
try {
|
|
2833
|
+
container.removeChild(gl.canvas);
|
|
2834
|
+
} catch (e) {
|
|
2835
|
+
void e;
|
|
2836
|
+
}
|
|
2837
|
+
meshRef.current = null;
|
|
2838
|
+
triRef.current = null;
|
|
2839
|
+
programRef.current = null;
|
|
2840
|
+
try {
|
|
2841
|
+
const glCtx = rendererRef.current?.gl;
|
|
2842
|
+
if (glCtx && gradTexRef.current?.texture)
|
|
2843
|
+
glCtx.deleteTexture(gradTexRef.current.texture);
|
|
2844
|
+
} catch (e) {
|
|
2845
|
+
void e;
|
|
2846
|
+
}
|
|
2847
|
+
rendererRef.current = null;
|
|
2848
|
+
gradTexRef.current = null;
|
|
2849
|
+
};
|
|
2850
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2851
|
+
}, []);
|
|
2852
|
+
|
|
2853
|
+
useEffect(() => {
|
|
2854
|
+
const canvas = rendererRef.current?.gl?.canvas as
|
|
2855
|
+
| HTMLCanvasElement
|
|
2856
|
+
| undefined;
|
|
2857
|
+
if (canvas) {
|
|
2858
|
+
canvas.style.mixBlendMode =
|
|
2859
|
+
mixBlendMode && mixBlendMode !== "none" ? mixBlendMode : "";
|
|
2860
|
+
}
|
|
2861
|
+
}, [mixBlendMode]);
|
|
2862
|
+
|
|
2863
|
+
useEffect(() => {
|
|
2864
|
+
const program = programRef.current;
|
|
2865
|
+
const renderer = rendererRef.current;
|
|
2866
|
+
const gradTex = gradTexRef.current;
|
|
2867
|
+
if (!program || !renderer || !gradTex) return;
|
|
2868
|
+
|
|
2869
|
+
program.uniforms.uIntensity.value = intensity ?? 1;
|
|
2870
|
+
program.uniforms.uSpeed.value = speed ?? 1;
|
|
2871
|
+
|
|
2872
|
+
const animTypeMap: Record<AnimationType, number> = {
|
|
2873
|
+
rotate: 0,
|
|
2874
|
+
rotate3d: 1,
|
|
2875
|
+
hover: 2,
|
|
2876
|
+
};
|
|
2877
|
+
program.uniforms.uAnimType.value = animTypeMap[animationType ?? "rotate"];
|
|
2878
|
+
|
|
2879
|
+
program.uniforms.uDistort.value = typeof distort === "number" ? distort : 0;
|
|
2880
|
+
|
|
2881
|
+
const ox = toPx(offset?.x);
|
|
2882
|
+
const oy = toPx(offset?.y);
|
|
2883
|
+
program.uniforms.uOffset.value = [ox, oy];
|
|
2884
|
+
program.uniforms.uRayCount.value = Math.max(0, Math.floor(rayCount ?? 0));
|
|
2885
|
+
|
|
2886
|
+
let count = 0;
|
|
2887
|
+
if (Array.isArray(colors) && colors.length > 0) {
|
|
2888
|
+
const gl = renderer.gl;
|
|
2889
|
+
const capped = colors.slice(0, 64);
|
|
2890
|
+
count = capped.length;
|
|
2891
|
+
const data = new Uint8Array(count * 4);
|
|
2892
|
+
for (let i = 0; i < count; i++) {
|
|
2893
|
+
const [r, g, b] = hexToRgb01(capped[i]);
|
|
2894
|
+
data[i * 4 + 0] = Math.round(r * 255);
|
|
2895
|
+
data[i * 4 + 1] = Math.round(g * 255);
|
|
2896
|
+
data[i * 4 + 2] = Math.round(b * 255);
|
|
2897
|
+
data[i * 4 + 3] = 255;
|
|
2898
|
+
}
|
|
2899
|
+
gradTex.image = data;
|
|
2900
|
+
gradTex.width = count;
|
|
2901
|
+
gradTex.height = 1;
|
|
2902
|
+
gradTex.minFilter = gl.LINEAR;
|
|
2903
|
+
gradTex.magFilter = gl.LINEAR;
|
|
2904
|
+
gradTex.wrapS = gl.CLAMP_TO_EDGE;
|
|
2905
|
+
gradTex.wrapT = gl.CLAMP_TO_EDGE;
|
|
2906
|
+
gradTex.flipY = false;
|
|
2907
|
+
gradTex.generateMipmaps = false;
|
|
2908
|
+
gradTex.format = gl.RGBA;
|
|
2909
|
+
gradTex.type = gl.UNSIGNED_BYTE;
|
|
2910
|
+
gradTex.needsUpdate = true;
|
|
2911
|
+
} else {
|
|
2912
|
+
count = 0;
|
|
2913
|
+
}
|
|
2914
|
+
program.uniforms.uColorCount.value = count;
|
|
2915
|
+
}, [intensity, speed, animationType, colors, distort, offset, rayCount]);
|
|
2916
|
+
|
|
2917
|
+
return (
|
|
2918
|
+
<div className="w-full h-full relative overflow-hidden" ref={containerRef} />
|
|
2919
|
+
);
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
export default PrismaticBurst;
|
|
2923
|
+
`,
|
|
2924
|
+
"components/glass/button": `import { cn } from "__UTILS_ALIAS__/cn";
|
|
2925
|
+
import { ButtonHTMLAttributes, forwardRef } from "react";
|
|
2926
|
+
|
|
2927
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
2928
|
+
variant?: "primary" | "secondary" | "ghost" | "gradient" | "glow-border";
|
|
2929
|
+
size?: "sm" | "md" | "lg";
|
|
2930
|
+
children: React.ReactNode;
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
2934
|
+
({ className, variant = "primary", size = "md", children, ...props }, ref) => {
|
|
2935
|
+
return (
|
|
2936
|
+
<button
|
|
2937
|
+
ref={ref}
|
|
2938
|
+
className={cn(
|
|
2939
|
+
"inline-flex items-center justify-center gap-2 font-semibold transition-all duration-300 rounded-full focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed relative overflow-hidden",
|
|
2940
|
+
{
|
|
2941
|
+
"bg-gradient-to-r from-[#00D4FF] to-[#00FF88] text-black hover:shadow-[0_0_30px_rgba(0,229,160,0.4)] hover:-translate-y-0.5":
|
|
2942
|
+
variant === "gradient",
|
|
2943
|
+
"bg-[#00E5A0] text-black hover:bg-[#00C488] hover:shadow-[0_0_20px_rgba(0,229,160,0.4)] hover:-translate-y-0.5":
|
|
2944
|
+
variant === "primary",
|
|
2945
|
+
"bg-transparent text-white border border-white/10 hover:border-[#00D4FF]/50 hover:text-[#00E5A0] hover:shadow-[0_0_20px_rgba(0,212,255,0.2)] hover:-translate-y-0.5":
|
|
2946
|
+
variant === "secondary",
|
|
2947
|
+
"bg-transparent text-white hover:bg-white/5 hover:text-[#00E5A0]":
|
|
2948
|
+
variant === "ghost",
|
|
2949
|
+
"btn-glow-border text-white hover:-translate-y-0.5":
|
|
2950
|
+
variant === "glow-border",
|
|
2951
|
+
},
|
|
2952
|
+
{
|
|
2953
|
+
"px-4 py-2 text-sm": size === "sm",
|
|
2954
|
+
"px-6 py-3 text-base": size === "md",
|
|
2955
|
+
"px-8 py-4 text-lg": size === "lg",
|
|
2956
|
+
},
|
|
2957
|
+
className
|
|
2958
|
+
)}
|
|
2959
|
+
{...props}
|
|
2960
|
+
>
|
|
2961
|
+
<span className="relative z-10 flex items-center gap-2">{children}</span>
|
|
2962
|
+
</button>
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
);
|
|
2966
|
+
|
|
2967
|
+
Button.displayName = "Button";
|
|
2968
|
+
|
|
2969
|
+
export { Button };
|
|
2970
|
+
`,
|
|
2971
|
+
"components/glass/card": `import { cn } from "__UTILS_ALIAS__/cn";
|
|
2972
|
+
import { HTMLAttributes, forwardRef } from "react";
|
|
2973
|
+
|
|
2974
|
+
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
2975
|
+
variant?: "default" | "strong" | "gradient" | "outline";
|
|
2976
|
+
hover?: boolean;
|
|
2977
|
+
children: React.ReactNode;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
2981
|
+
({ className, variant = "default", hover = false, children, ...props }, ref) => {
|
|
2982
|
+
return (
|
|
2983
|
+
<div
|
|
2984
|
+
ref={ref}
|
|
2985
|
+
className={cn(
|
|
2986
|
+
"rounded-2xl transition-all duration-300",
|
|
2987
|
+
{
|
|
2988
|
+
"bg-white/[0.06] backdrop-blur-xl border border-white/[0.08] shadow-lg shadow-black/10":
|
|
2989
|
+
variant === "default",
|
|
2990
|
+
"bg-white/[0.1] backdrop-blur-xl border border-white/[0.1] shadow-lg shadow-black/15":
|
|
2991
|
+
variant === "strong",
|
|
2992
|
+
"bg-gradient-to-br from-[#00D4FF]/[0.08] to-[#00FF88]/[0.05] backdrop-blur-xl border border-white/[0.1] shadow-lg shadow-black/15":
|
|
2993
|
+
variant === "gradient",
|
|
2994
|
+
"bg-transparent backdrop-blur-sm border border-white/[0.1] hover:border-[#00D4FF]/30":
|
|
2995
|
+
variant === "outline",
|
|
2996
|
+
},
|
|
2997
|
+
hover && "hover:bg-white/[0.1] hover:border-[#00D4FF]/20 hover:shadow-[0_8px_32px_rgba(0,212,255,0.1)] hover:-translate-y-1 cursor-pointer",
|
|
2998
|
+
className
|
|
2999
|
+
)}
|
|
3000
|
+
{...props}
|
|
3001
|
+
>
|
|
3002
|
+
{children}
|
|
3003
|
+
</div>
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
);
|
|
3007
|
+
|
|
3008
|
+
Card.displayName = "Card";
|
|
3009
|
+
|
|
3010
|
+
export { Card };
|
|
3011
|
+
`,
|
|
3012
|
+
"components/glass/modal": `"use client";
|
|
3013
|
+
|
|
3014
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
3015
|
+
import { useEffect, useCallback } from "react";
|
|
3016
|
+
import { createPortal } from "react-dom";
|
|
3017
|
+
|
|
3018
|
+
interface ModalProps {
|
|
3019
|
+
isOpen: boolean;
|
|
3020
|
+
onClose: () => void;
|
|
3021
|
+
children: React.ReactNode;
|
|
3022
|
+
className?: string;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
export function Modal({ isOpen, onClose, children, className }: ModalProps) {
|
|
3026
|
+
const handleEscape = useCallback(
|
|
3027
|
+
(e: KeyboardEvent) => {
|
|
3028
|
+
if (e.key === "Escape") onClose();
|
|
3029
|
+
},
|
|
3030
|
+
[onClose]
|
|
3031
|
+
);
|
|
3032
|
+
|
|
3033
|
+
useEffect(() => {
|
|
3034
|
+
if (isOpen) {
|
|
3035
|
+
document.addEventListener("keydown", handleEscape);
|
|
3036
|
+
document.body.style.overflow = "hidden";
|
|
3037
|
+
}
|
|
3038
|
+
return () => {
|
|
3039
|
+
document.removeEventListener("keydown", handleEscape);
|
|
3040
|
+
document.body.style.overflow = "";
|
|
3041
|
+
};
|
|
3042
|
+
}, [isOpen, handleEscape]);
|
|
3043
|
+
|
|
3044
|
+
if (!isOpen) return null;
|
|
3045
|
+
|
|
3046
|
+
return createPortal(
|
|
3047
|
+
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
|
3048
|
+
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
|
3049
|
+
<div className={cn(
|
|
3050
|
+
"relative z-10 w-full sm:max-w-2xl max-h-[85vh] sm:max-h-[90vh] overflow-auto bg-[#0a0a0a] sm:glass-strong animate-slide-up rounded-t-2xl sm:rounded-2xl",
|
|
3051
|
+
className
|
|
3052
|
+
)}>
|
|
3053
|
+
<button
|
|
3054
|
+
onClick={onClose}
|
|
3055
|
+
className="absolute top-3 sm:top-4 right-3 sm:right-4 w-9 sm:w-10 h-9 sm:h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors text-gray-400 hover:text-white z-10"
|
|
3056
|
+
aria-label="Close modal"
|
|
3057
|
+
>
|
|
3058
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3059
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
3060
|
+
</svg>
|
|
3061
|
+
</button>
|
|
3062
|
+
<div className="sm:hidden flex justify-center pt-3 pb-1">
|
|
3063
|
+
<div className="w-10 h-1 rounded-full bg-white/20" />
|
|
3064
|
+
</div>
|
|
3065
|
+
{children}
|
|
3066
|
+
</div>
|
|
3067
|
+
</div>,
|
|
3068
|
+
document.body
|
|
3069
|
+
);
|
|
3070
|
+
}
|
|
3071
|
+
`,
|
|
3072
|
+
"components/glass/music-player": `"use client";
|
|
3073
|
+
|
|
3074
|
+
import { useState, useRef, useEffect } from "react";
|
|
3075
|
+
import { GlassSurface } from "__COMPONENTS_ALIAS__/glass-surface";
|
|
3076
|
+
|
|
3077
|
+
interface MusicPlayerProps {
|
|
3078
|
+
src: string;
|
|
3079
|
+
initialVolume?: number;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
export function MusicPlayer({ src, initialVolume = 0.5 }: MusicPlayerProps) {
|
|
3083
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
3084
|
+
const [isDesktop, setIsDesktop] = useState(false);
|
|
3085
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
3086
|
+
|
|
3087
|
+
useEffect(() => {
|
|
3088
|
+
if (audioRef.current) {
|
|
3089
|
+
audioRef.current.volume = initialVolume;
|
|
3090
|
+
}
|
|
3091
|
+
}, [initialVolume]);
|
|
3092
|
+
|
|
3093
|
+
useEffect(() => {
|
|
3094
|
+
const handleResize = () => setIsDesktop(window.innerWidth >= 768);
|
|
3095
|
+
handleResize();
|
|
3096
|
+
window.addEventListener("resize", handleResize);
|
|
3097
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
3098
|
+
}, []);
|
|
3099
|
+
|
|
3100
|
+
const togglePlay = () => {
|
|
3101
|
+
if (audioRef.current) {
|
|
3102
|
+
if (isPlaying) {
|
|
3103
|
+
audioRef.current.pause();
|
|
3104
|
+
} else {
|
|
3105
|
+
audioRef.current.play();
|
|
3106
|
+
}
|
|
3107
|
+
setIsPlaying(!isPlaying);
|
|
3108
|
+
}
|
|
3109
|
+
};
|
|
3110
|
+
|
|
3111
|
+
const buttonContent = (
|
|
3112
|
+
<>
|
|
3113
|
+
<div className="flex items-center gap-[3px] h-5">
|
|
3114
|
+
{[0, 1, 2, 3, 4].map((i) => (
|
|
3115
|
+
<div
|
|
3116
|
+
key={i}
|
|
3117
|
+
className={\`w-[3px] bg-gradient-to-t from-[#00D4FF] to-[#00FF88] rounded-full transition-all \${
|
|
3118
|
+
isPlaying ? "animate-soundwave" : "h-1"
|
|
3119
|
+
}\`}
|
|
3120
|
+
style={{ animationDelay: isPlaying ? \`\${i * 0.1}s\` : undefined }}
|
|
3121
|
+
/>
|
|
3122
|
+
))}
|
|
3123
|
+
</div>
|
|
3124
|
+
<span className="text-sm text-white/60 hidden sm:inline">
|
|
3125
|
+
{isPlaying ? "ON" : "OFF"}
|
|
3126
|
+
</span>
|
|
3127
|
+
</>
|
|
3128
|
+
);
|
|
3129
|
+
|
|
3130
|
+
return (
|
|
3131
|
+
<>
|
|
3132
|
+
<audio ref={audioRef} src={src} loop />
|
|
3133
|
+
{!isDesktop && (
|
|
3134
|
+
<button
|
|
3135
|
+
onClick={togglePlay}
|
|
3136
|
+
className={\`h-14 sm:h-16 w-14 sm:w-auto sm:px-4 flex items-center justify-center sm:justify-start gap-2 rounded-full bg-white/[0.08] backdrop-blur-xl border border-white/[0.08] hover:bg-white/[0.12] transition-all shadow-lg shadow-black/10 \${
|
|
3137
|
+
!isPlaying ? "animate-attention-ring" : ""
|
|
3138
|
+
}\`}
|
|
3139
|
+
aria-label={isPlaying ? "Pause music" : "Play music"}
|
|
3140
|
+
>
|
|
3141
|
+
{buttonContent}
|
|
3142
|
+
</button>
|
|
3143
|
+
)}
|
|
3144
|
+
{isDesktop && (
|
|
3145
|
+
<GlassSurface
|
|
3146
|
+
borderRadius={9999}
|
|
3147
|
+
backgroundOpacity={0.1}
|
|
3148
|
+
brightness={50}
|
|
3149
|
+
blur={11}
|
|
3150
|
+
displace={0.5}
|
|
3151
|
+
distortionScale={-180}
|
|
3152
|
+
redOffset={0}
|
|
3153
|
+
greenOffset={10}
|
|
3154
|
+
blueOffset={20}
|
|
3155
|
+
className={\`cursor-pointer hover:scale-105 transition-transform \${
|
|
3156
|
+
!isPlaying ? "animate-attention-ring" : ""
|
|
3157
|
+
}\`}
|
|
3158
|
+
>
|
|
3159
|
+
<button
|
|
3160
|
+
onClick={togglePlay}
|
|
3161
|
+
className="h-14 sm:h-16 w-14 sm:w-auto sm:px-4 flex items-center justify-center sm:justify-start gap-2"
|
|
3162
|
+
aria-label={isPlaying ? "Pause music" : "Play music"}
|
|
3163
|
+
>
|
|
3164
|
+
{buttonContent}
|
|
3165
|
+
</button>
|
|
3166
|
+
</GlassSurface>
|
|
3167
|
+
)}
|
|
3168
|
+
</>
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
`,
|
|
3172
|
+
"components/motion/countdown-timer": `"use client";
|
|
3173
|
+
|
|
3174
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
3175
|
+
import { useCountdown } from "__HOOKS_ALIAS__/use-countdown";
|
|
3176
|
+
import { useMemo } from "react";
|
|
3177
|
+
|
|
3178
|
+
interface CountdownTimerProps {
|
|
3179
|
+
targetDate: Date;
|
|
3180
|
+
endDate?: Date;
|
|
3181
|
+
className?: string;
|
|
3182
|
+
labels?: {
|
|
3183
|
+
days?: string;
|
|
3184
|
+
hours?: string;
|
|
3185
|
+
mins?: string;
|
|
3186
|
+
secs?: string;
|
|
3187
|
+
};
|
|
3188
|
+
endedText?: [string, string];
|
|
3189
|
+
liveText?: string;
|
|
3190
|
+
todayText?: [string, string]; // [title, subtitle template with {time}]
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
type CountdownState = "normal" | "approaching" | "imminent" | "today-waiting" | "live" | "ended";
|
|
3194
|
+
|
|
3195
|
+
function getCountdownState(targetDate: Date, endDate?: Date): CountdownState {
|
|
3196
|
+
const now = new Date();
|
|
3197
|
+
const msUntilStart = targetDate.getTime() - now.getTime();
|
|
3198
|
+
const msUntilEnd = endDate ? endDate.getTime() - now.getTime() : msUntilStart;
|
|
3199
|
+
|
|
3200
|
+
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
3201
|
+
const sevenDaysMs = 7 * oneDayMs;
|
|
3202
|
+
|
|
3203
|
+
if (msUntilEnd <= 0) return "ended";
|
|
3204
|
+
if (msUntilStart <= 0 && msUntilEnd > 0) return "live";
|
|
3205
|
+
|
|
3206
|
+
const isSameDay =
|
|
3207
|
+
now.getFullYear() === targetDate.getFullYear() &&
|
|
3208
|
+
now.getMonth() === targetDate.getMonth() &&
|
|
3209
|
+
now.getDate() === targetDate.getDate();
|
|
3210
|
+
|
|
3211
|
+
if (isSameDay && msUntilStart > 0) return "today-waiting";
|
|
3212
|
+
if (msUntilStart > 0 && msUntilStart <= oneDayMs) return "imminent";
|
|
3213
|
+
if (msUntilStart > 0 && msUntilStart <= sevenDaysMs) return "approaching";
|
|
3214
|
+
|
|
3215
|
+
return "normal";
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function TimeUnit({ value, label, state }: { value: number; label: string; state: CountdownState }) {
|
|
3219
|
+
const stateStyles = {
|
|
3220
|
+
normal: { text: "gradient-text glow-text-gradient", glass: "glass" },
|
|
3221
|
+
approaching: { text: "animate-ember", glass: "glass animate-ember-glow" },
|
|
3222
|
+
imminent: { text: "text-rose-400 drop-shadow-[0_0_25px_rgba(251,113,133,0.8)] animate-pulse", glass: "glass border-rose-500/40 shadow-[0_0_40px_rgba(251,113,133,0.3)] animate-shake" },
|
|
3223
|
+
"today-waiting": { text: "text-emerald-400 drop-shadow-[0_0_20px_rgba(52,211,153,0.7)]", glass: "glass border-emerald-500/30 shadow-[0_0_30px_rgba(52,211,153,0.2)]" },
|
|
3224
|
+
live: { text: "gradient-text", glass: "glass" },
|
|
3225
|
+
ended: { text: "gradient-text", glass: "glass" },
|
|
3226
|
+
};
|
|
3227
|
+
const styles = stateStyles[state];
|
|
3228
|
+
|
|
3229
|
+
return (
|
|
3230
|
+
<div className="flex flex-col items-center">
|
|
3231
|
+
<div className={cn("px-3 py-2 sm:px-5 sm:py-3 md:px-6 md:py-4 min-w-[56px] sm:min-w-[72px] md:min-w-[90px] transition-all duration-300", styles.glass)}>
|
|
3232
|
+
<span className={cn("text-2xl sm:text-4xl md:text-5xl font-bold tabular-nums transition-all duration-300", styles.text)} suppressHydrationWarning>
|
|
3233
|
+
{value.toString().padStart(2, "0")}
|
|
3234
|
+
</span>
|
|
3235
|
+
</div>
|
|
3236
|
+
<span className={cn("text-[10px] sm:text-xs md:text-sm mt-1.5 sm:mt-2 uppercase tracking-wider transition-colors duration-300",
|
|
3237
|
+
state === "approaching" ? "text-orange-500/80" :
|
|
3238
|
+
state === "imminent" ? "text-rose-400/70" :
|
|
3239
|
+
state === "today-waiting" ? "text-emerald-400/70" :
|
|
3240
|
+
"text-gray-400"
|
|
3241
|
+
)}>
|
|
3242
|
+
{label}
|
|
3243
|
+
</span>
|
|
3244
|
+
</div>
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
function Separator({ state }: { state: CountdownState }) {
|
|
3249
|
+
const colorClass =
|
|
3250
|
+
state === "approaching" ? "text-orange-500 animate-ember" :
|
|
3251
|
+
state === "imminent" ? "text-rose-400 animate-pulse" :
|
|
3252
|
+
state === "today-waiting" ? "text-emerald-400" :
|
|
3253
|
+
"gradient-text";
|
|
3254
|
+
|
|
3255
|
+
return (
|
|
3256
|
+
<div className={cn("flex items-center text-xl sm:text-3xl md:text-4xl self-start mt-3 sm:mt-4 transition-colors duration-300", colorClass)}>:</div>
|
|
3257
|
+
);
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function LiveIndicator({ text }: { text: string }) {
|
|
3261
|
+
return (
|
|
3262
|
+
<div className="flex items-center justify-center gap-4 sm:gap-6">
|
|
3263
|
+
<span className="relative flex h-5 w-5 sm:h-6 sm:w-6 md:h-8 md:w-8">
|
|
3264
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
|
|
3265
|
+
<span className="relative inline-flex rounded-full h-5 w-5 sm:h-6 sm:w-6 md:h-8 md:w-8 bg-red-500 shadow-[0_0_20px_rgba(239,68,68,0.9)]"></span>
|
|
3266
|
+
</span>
|
|
3267
|
+
<span className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-red-400 tracking-wider animate-pulse drop-shadow-[0_0_30px_rgba(239,68,68,0.8)]">
|
|
3268
|
+
{text}
|
|
3269
|
+
</span>
|
|
3270
|
+
<span className="relative flex h-5 w-5 sm:h-6 sm:w-6 md:h-8 md:w-8">
|
|
3271
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
|
|
3272
|
+
<span className="relative inline-flex rounded-full h-5 w-5 sm:h-6 sm:w-6 md:h-8 md:w-8 bg-red-500 shadow-[0_0_20px_rgba(239,68,68,0.9)]"></span>
|
|
3273
|
+
</span>
|
|
3274
|
+
</div>
|
|
3275
|
+
);
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
export function CountdownTimer({
|
|
3279
|
+
targetDate,
|
|
3280
|
+
endDate,
|
|
3281
|
+
className,
|
|
3282
|
+
labels = {},
|
|
3283
|
+
endedText = ["Event", "Ended"],
|
|
3284
|
+
liveText = "LIVE NOW",
|
|
3285
|
+
todayText,
|
|
3286
|
+
}: CountdownTimerProps) {
|
|
3287
|
+
const timeLeft = useCountdown(targetDate);
|
|
3288
|
+
const daysLabel = labels.days ?? "Days";
|
|
3289
|
+
const hoursLabel = labels.hours ?? "Hours";
|
|
3290
|
+
const minsLabel = labels.mins ?? "Mins";
|
|
3291
|
+
const secsLabel = labels.secs ?? "Secs";
|
|
3292
|
+
|
|
3293
|
+
const state = useMemo(
|
|
3294
|
+
() => getCountdownState(targetDate, endDate),
|
|
3295
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3296
|
+
[targetDate, endDate, timeLeft.total]
|
|
3297
|
+
);
|
|
3298
|
+
|
|
3299
|
+
if (state === "ended") {
|
|
3300
|
+
return (
|
|
3301
|
+
<div className={cn("text-center flex flex-col items-center gap-1 sm:gap-2", className)}>
|
|
3302
|
+
<span className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-wide"
|
|
3303
|
+
style={{ background: "linear-gradient(135deg, #FFD700, #FFA500, #FFE066)", backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", filter: "drop-shadow(0 0 20px rgba(255,215,0,0.5))" }}>
|
|
3304
|
+
{endedText[0]}
|
|
3305
|
+
</span>
|
|
3306
|
+
<span className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-wide"
|
|
3307
|
+
style={{ background: "linear-gradient(135deg, #FFD700, #FFA500, #FFE066)", backgroundClip: "text", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", filter: "drop-shadow(0 0 20px rgba(255,215,0,0.5))" }}>
|
|
3308
|
+
{endedText[1]}
|
|
3309
|
+
</span>
|
|
3310
|
+
</div>
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
if (state === "live") {
|
|
3315
|
+
return (
|
|
3316
|
+
<div className={cn("text-center", className)}>
|
|
3317
|
+
<LiveIndicator text={liveText} />
|
|
3318
|
+
</div>
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
if (state === "today-waiting") {
|
|
3323
|
+
return (
|
|
3324
|
+
<div className={cn("flex gap-1.5 sm:gap-3 md:gap-4 flex-wrap", className)}>
|
|
3325
|
+
<TimeUnit value={timeLeft.hours} label={hoursLabel} state={state} />
|
|
3326
|
+
<Separator state={state} />
|
|
3327
|
+
<TimeUnit value={timeLeft.minutes} label={minsLabel} state={state} />
|
|
3328
|
+
<Separator state={state} />
|
|
3329
|
+
<TimeUnit value={timeLeft.seconds} label={secsLabel} state={state} />
|
|
3330
|
+
</div>
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
return (
|
|
3335
|
+
<div className={cn("flex flex-col items-center", className)}>
|
|
3336
|
+
<div className="flex gap-1.5 sm:gap-3 md:gap-4 flex-wrap justify-center">
|
|
3337
|
+
<TimeUnit value={timeLeft.days} label={daysLabel} state={state} />
|
|
3338
|
+
<Separator state={state} />
|
|
3339
|
+
<TimeUnit value={timeLeft.hours} label={hoursLabel} state={state} />
|
|
3340
|
+
<Separator state={state} />
|
|
3341
|
+
<TimeUnit value={timeLeft.minutes} label={minsLabel} state={state} />
|
|
3342
|
+
<Separator state={state} />
|
|
3343
|
+
<TimeUnit value={timeLeft.seconds} label={secsLabel} state={state} />
|
|
3344
|
+
</div>
|
|
3345
|
+
</div>
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
`,
|
|
3349
|
+
"components/motion/counter": `"use client";
|
|
3350
|
+
|
|
3351
|
+
import { useEffect, useState, useRef } from "react";
|
|
3352
|
+
import { useScrollAnimation } from "__HOOKS_ALIAS__/use-scroll-animation";
|
|
3353
|
+
import { cn } from "__UTILS_ALIAS__/cn";
|
|
3354
|
+
|
|
3355
|
+
interface CounterProps {
|
|
3356
|
+
end: number;
|
|
3357
|
+
suffix?: string;
|
|
3358
|
+
duration?: number;
|
|
3359
|
+
className?: string;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
const CYBER_CHARS = "\u30A2 \u30A4 \u30A6 \u30A8 \u30AA \u30AB \u30AD \u30AF \u30B1 \u30B3 0 1 2 3 4 5 6 7 8 9 A B C D E F # $ % & @".split(" ");
|
|
3363
|
+
|
|
3364
|
+
export function Counter({ end, suffix = "", duration = 2500, className }: CounterProps) {
|
|
3365
|
+
const [displayText, setDisplayText] = useState("");
|
|
3366
|
+
const [ref, isInView] = useScrollAnimation<HTMLSpanElement>({ threshold: 0.5 });
|
|
3367
|
+
const hasAnimated = useRef(false);
|
|
3368
|
+
|
|
3369
|
+
useEffect(() => {
|
|
3370
|
+
if (isInView && !hasAnimated.current) {
|
|
3371
|
+
hasAnimated.current = true;
|
|
3372
|
+
|
|
3373
|
+
const targetText = end.toString();
|
|
3374
|
+
const totalLength = targetText.length;
|
|
3375
|
+
|
|
3376
|
+
const lockedChars: boolean[] = new Array(totalLength).fill(false);
|
|
3377
|
+
const currentChars: string[] = new Array(totalLength).fill("");
|
|
3378
|
+
|
|
3379
|
+
for (let i = 0; i < totalLength; i++) {
|
|
3380
|
+
currentChars[i] = CYBER_CHARS[Math.floor(Math.random() * CYBER_CHARS.length)];
|
|
3381
|
+
}
|
|
3382
|
+
setDisplayText(currentChars.join(""));
|
|
3383
|
+
|
|
3384
|
+
const intervals: ReturnType<typeof setInterval>[] = [];
|
|
3385
|
+
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
|
3386
|
+
|
|
3387
|
+
const scrambleInterval = setInterval(() => {
|
|
3388
|
+
for (let i = 0; i < totalLength; i++) {
|
|
3389
|
+
if (!lockedChars[i]) {
|
|
3390
|
+
currentChars[i] = CYBER_CHARS[Math.floor(Math.random() * CYBER_CHARS.length)];
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
setDisplayText(currentChars.join(""));
|
|
3394
|
+
}, 50);
|
|
3395
|
+
intervals.push(scrambleInterval);
|
|
3396
|
+
|
|
3397
|
+
const lockDelay = duration / (totalLength + 1);
|
|
3398
|
+
|
|
3399
|
+
for (let i = 0; i < totalLength; i++) {
|
|
3400
|
+
const lockTimeout = setTimeout(() => {
|
|
3401
|
+
let findCount = 0;
|
|
3402
|
+
const findInterval = setInterval(() => {
|
|
3403
|
+
currentChars[i] = CYBER_CHARS[Math.floor(Math.random() * CYBER_CHARS.length)];
|
|
3404
|
+
setDisplayText(currentChars.join(""));
|
|
3405
|
+
findCount++;
|
|
3406
|
+
if (findCount >= 5) {
|
|
3407
|
+
clearInterval(findInterval);
|
|
3408
|
+
lockedChars[i] = true;
|
|
3409
|
+
currentChars[i] = targetText[i];
|
|
3410
|
+
setDisplayText(currentChars.join(""));
|
|
3411
|
+
}
|
|
3412
|
+
}, 60);
|
|
3413
|
+
intervals.push(findInterval);
|
|
3414
|
+
}, lockDelay * (i + 1));
|
|
3415
|
+
timeouts.push(lockTimeout);
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
const finalTimeout = setTimeout(() => {
|
|
3419
|
+
clearInterval(scrambleInterval);
|
|
3420
|
+
setDisplayText(targetText);
|
|
3421
|
+
}, duration + 100);
|
|
3422
|
+
timeouts.push(finalTimeout);
|
|
3423
|
+
|
|
3424
|
+
return () => {
|
|
3425
|
+
intervals.forEach(clearInterval);
|
|
3426
|
+
timeouts.forEach(clearTimeout);
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
}, [isInView, end, duration]);
|
|
3430
|
+
|
|
3431
|
+
return (
|
|
3432
|
+
<span ref={ref} className={cn("tabular-nums", className)}>
|
|
3433
|
+
{displayText}{suffix}
|
|
3434
|
+
</span>
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
`,
|
|
3438
|
+
"components/motion/light-rays": `"use client";
|
|
3439
|
+
|
|
3440
|
+
import { useRef, useEffect, useState } from 'react';
|
|
3441
|
+
import { Renderer, Program, Triangle, Mesh } from 'ogl';
|
|
3442
|
+
|
|
3443
|
+
export type RaysOrigin =
|
|
3444
|
+
| 'top-center'
|
|
3445
|
+
| 'top-left'
|
|
3446
|
+
| 'top-right'
|
|
3447
|
+
| 'right'
|
|
3448
|
+
| 'left'
|
|
3449
|
+
| 'bottom-center'
|
|
3450
|
+
| 'bottom-right'
|
|
3451
|
+
| 'bottom-left';
|
|
3452
|
+
|
|
3453
|
+
interface LightRaysProps {
|
|
3454
|
+
raysOrigin?: RaysOrigin;
|
|
3455
|
+
raysColor?: string;
|
|
3456
|
+
raysSpeed?: number;
|
|
3457
|
+
lightSpread?: number;
|
|
3458
|
+
rayLength?: number;
|
|
3459
|
+
pulsating?: boolean;
|
|
3460
|
+
fadeDistance?: number;
|
|
3461
|
+
saturation?: number;
|
|
3462
|
+
followMouse?: boolean;
|
|
3463
|
+
mouseInfluence?: number;
|
|
3464
|
+
noiseAmount?: number;
|
|
3465
|
+
distortion?: number;
|
|
3466
|
+
className?: string;
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
const DEFAULT_COLOR = '#ffffff';
|
|
3470
|
+
|
|
3471
|
+
const hexToRgb = (hex: string): [number, number, number] => {
|
|
3472
|
+
const m = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);
|
|
3473
|
+
return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1];
|
|
3474
|
+
};
|
|
3475
|
+
|
|
3476
|
+
const getAnchorAndDir = (
|
|
3477
|
+
origin: RaysOrigin,
|
|
3478
|
+
w: number,
|
|
3479
|
+
h: number
|
|
3480
|
+
): { anchor: [number, number]; dir: [number, number] } => {
|
|
3481
|
+
const outside = 0.2;
|
|
3482
|
+
switch (origin) {
|
|
3483
|
+
case 'top-left':
|
|
3484
|
+
return { anchor: [0, -outside * h], dir: [0, 1] };
|
|
3485
|
+
case 'top-right':
|
|
3486
|
+
return { anchor: [w, -outside * h], dir: [0, 1] };
|
|
3487
|
+
case 'left':
|
|
3488
|
+
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
|
|
3489
|
+
case 'right':
|
|
3490
|
+
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
|
|
3491
|
+
case 'bottom-left':
|
|
3492
|
+
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
|
|
3493
|
+
case 'bottom-center':
|
|
3494
|
+
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
|
|
3495
|
+
case 'bottom-right':
|
|
3496
|
+
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
|
|
3497
|
+
default: // "top-center"
|
|
3498
|
+
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
|
|
3499
|
+
}
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
type Vec2 = [number, number];
|
|
3503
|
+
type Vec3 = [number, number, number];
|
|
3504
|
+
|
|
3505
|
+
interface Uniforms {
|
|
3506
|
+
iTime: { value: number };
|
|
3507
|
+
iResolution: { value: Vec2 };
|
|
3508
|
+
rayPos: { value: Vec2 };
|
|
3509
|
+
rayDir: { value: Vec2 };
|
|
3510
|
+
raysColor: { value: Vec3 };
|
|
3511
|
+
raysSpeed: { value: number };
|
|
3512
|
+
lightSpread: { value: number };
|
|
3513
|
+
rayLength: { value: number };
|
|
3514
|
+
pulsating: { value: number };
|
|
3515
|
+
fadeDistance: { value: number };
|
|
3516
|
+
saturation: { value: number };
|
|
3517
|
+
mousePos: { value: Vec2 };
|
|
3518
|
+
mouseInfluence: { value: number };
|
|
3519
|
+
noiseAmount: { value: number };
|
|
3520
|
+
distortion: { value: number };
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
const LightRays: React.FC<LightRaysProps> = ({
|
|
3524
|
+
raysOrigin = 'top-center',
|
|
3525
|
+
raysColor = DEFAULT_COLOR,
|
|
3526
|
+
raysSpeed = 1,
|
|
3527
|
+
lightSpread = 1,
|
|
3528
|
+
rayLength = 2,
|
|
3529
|
+
pulsating = false,
|
|
3530
|
+
fadeDistance = 1.0,
|
|
3531
|
+
saturation = 1.0,
|
|
3532
|
+
followMouse = true,
|
|
3533
|
+
mouseInfluence = 0.1,
|
|
3534
|
+
noiseAmount = 0.0,
|
|
3535
|
+
distortion = 0.0,
|
|
3536
|
+
className = ''
|
|
3537
|
+
}) => {
|
|
3538
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
3539
|
+
const uniformsRef = useRef<Uniforms | null>(null);
|
|
3540
|
+
const rendererRef = useRef<Renderer | null>(null);
|
|
3541
|
+
const mouseRef = useRef({ x: 0.5, y: 0.5 });
|
|
3542
|
+
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
|
|
3543
|
+
const animationIdRef = useRef<number | null>(null);
|
|
3544
|
+
const meshRef = useRef<Mesh | null>(null);
|
|
3545
|
+
const cleanupFunctionRef = useRef<(() => void) | null>(null);
|
|
3546
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
3547
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
3548
|
+
|
|
3549
|
+
useEffect(() => {
|
|
3550
|
+
if (!containerRef.current) return;
|
|
3551
|
+
|
|
3552
|
+
observerRef.current = new IntersectionObserver(
|
|
3553
|
+
entries => {
|
|
3554
|
+
const entry = entries[0];
|
|
3555
|
+
setIsVisible(entry.isIntersecting);
|
|
3556
|
+
},
|
|
3557
|
+
{ threshold: 0.1 }
|
|
3558
|
+
);
|
|
3559
|
+
|
|
3560
|
+
observerRef.current.observe(containerRef.current);
|
|
3561
|
+
|
|
3562
|
+
return () => {
|
|
3563
|
+
if (observerRef.current) {
|
|
3564
|
+
observerRef.current.disconnect();
|
|
3565
|
+
observerRef.current = null;
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
}, []);
|
|
3569
|
+
|
|
3570
|
+
useEffect(() => {
|
|
3571
|
+
if (!isVisible || !containerRef.current) return;
|
|
3572
|
+
|
|
3573
|
+
if (cleanupFunctionRef.current) {
|
|
3574
|
+
cleanupFunctionRef.current();
|
|
3575
|
+
cleanupFunctionRef.current = null;
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
const initializeWebGL = async () => {
|
|
3579
|
+
if (!containerRef.current) return;
|
|
3580
|
+
|
|
3581
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
3582
|
+
|
|
3583
|
+
if (!containerRef.current) return;
|
|
3584
|
+
|
|
3585
|
+
const renderer = new Renderer({
|
|
3586
|
+
dpr: Math.min(window.devicePixelRatio, 2),
|
|
3587
|
+
alpha: true
|
|
3588
|
+
});
|
|
3589
|
+
rendererRef.current = renderer;
|
|
3590
|
+
|
|
3591
|
+
const gl = renderer.gl;
|
|
3592
|
+
gl.canvas.style.width = '100%';
|
|
3593
|
+
gl.canvas.style.height = '100%';
|
|
3594
|
+
|
|
3595
|
+
while (containerRef.current.firstChild) {
|
|
3596
|
+
containerRef.current.removeChild(containerRef.current.firstChild);
|
|
3597
|
+
}
|
|
3598
|
+
containerRef.current.appendChild(gl.canvas);
|
|
3599
|
+
|
|
3600
|
+
const vert = \`
|
|
3601
|
+
attribute vec2 position;
|
|
3602
|
+
varying vec2 vUv;
|
|
3603
|
+
void main() {
|
|
3604
|
+
vUv = position * 0.5 + 0.5;
|
|
3605
|
+
gl_Position = vec4(position, 0.0, 1.0);
|
|
3606
|
+
}\`;
|
|
3607
|
+
|
|
3608
|
+
const frag = \`precision highp float;
|
|
3609
|
+
|
|
3610
|
+
uniform float iTime;
|
|
3611
|
+
uniform vec2 iResolution;
|
|
3612
|
+
|
|
3613
|
+
uniform vec2 rayPos;
|
|
3614
|
+
uniform vec2 rayDir;
|
|
3615
|
+
uniform vec3 raysColor;
|
|
3616
|
+
uniform float raysSpeed;
|
|
3617
|
+
uniform float lightSpread;
|
|
3618
|
+
uniform float rayLength;
|
|
3619
|
+
uniform float pulsating;
|
|
3620
|
+
uniform float fadeDistance;
|
|
3621
|
+
uniform float saturation;
|
|
3622
|
+
uniform vec2 mousePos;
|
|
3623
|
+
uniform float mouseInfluence;
|
|
3624
|
+
uniform float noiseAmount;
|
|
3625
|
+
uniform float distortion;
|
|
3626
|
+
|
|
3627
|
+
varying vec2 vUv;
|
|
3628
|
+
|
|
3629
|
+
float noise(vec2 st) {
|
|
3630
|
+
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
|
|
3634
|
+
float seedA, float seedB, float speed) {
|
|
3635
|
+
vec2 sourceToCoord = coord - raySource;
|
|
3636
|
+
vec2 dirNorm = normalize(sourceToCoord);
|
|
3637
|
+
float cosAngle = dot(dirNorm, rayRefDirection);
|
|
3638
|
+
|
|
3639
|
+
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
|
|
3640
|
+
|
|
3641
|
+
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
|
|
3642
|
+
|
|
3643
|
+
float distance = length(sourceToCoord);
|
|
3644
|
+
float maxDistance = iResolution.x * rayLength;
|
|
3645
|
+
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
|
|
3646
|
+
|
|
3647
|
+
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
|
|
3648
|
+
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
|
|
3649
|
+
|
|
3650
|
+
float baseStrength = clamp(
|
|
3651
|
+
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
|
|
3652
|
+
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
|
|
3653
|
+
0.0, 1.0
|
|
3654
|
+
);
|
|
3655
|
+
|
|
3656
|
+
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
3660
|
+
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
|
|
3661
|
+
|
|
3662
|
+
vec2 finalRayDir = rayDir;
|
|
3663
|
+
if (mouseInfluence > 0.0) {
|
|
3664
|
+
vec2 mouseScreenPos = mousePos * iResolution.xy;
|
|
3665
|
+
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
|
|
3666
|
+
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
vec4 rays1 = vec4(1.0) *
|
|
3670
|
+
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
|
|
3671
|
+
1.5 * raysSpeed);
|
|
3672
|
+
vec4 rays2 = vec4(1.0) *
|
|
3673
|
+
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
|
|
3674
|
+
1.1 * raysSpeed);
|
|
3675
|
+
|
|
3676
|
+
fragColor = rays1 * 0.5 + rays2 * 0.4;
|
|
3677
|
+
|
|
3678
|
+
if (noiseAmount > 0.0) {
|
|
3679
|
+
float n = noise(coord * 0.01 + iTime * 0.1);
|
|
3680
|
+
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
float brightness = 1.0 - (coord.y / iResolution.y);
|
|
3684
|
+
fragColor.x *= 0.1 + brightness * 0.8;
|
|
3685
|
+
fragColor.y *= 0.3 + brightness * 0.6;
|
|
3686
|
+
fragColor.z *= 0.5 + brightness * 0.5;
|
|
3687
|
+
|
|
3688
|
+
if (saturation != 1.0) {
|
|
3689
|
+
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
|
|
3690
|
+
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
fragColor.rgb *= raysColor;
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
void main() {
|
|
3697
|
+
vec4 color;
|
|
3698
|
+
mainImage(color, gl_FragCoord.xy);
|
|
3699
|
+
gl_FragColor = color;
|
|
3700
|
+
}\`;
|
|
3701
|
+
|
|
3702
|
+
const uniforms: Uniforms = {
|
|
3703
|
+
iTime: { value: 0 },
|
|
3704
|
+
iResolution: { value: [1, 1] },
|
|
3705
|
+
|
|
3706
|
+
rayPos: { value: [0, 0] },
|
|
3707
|
+
rayDir: { value: [0, 1] },
|
|
3708
|
+
|
|
3709
|
+
raysColor: { value: hexToRgb(raysColor) },
|
|
3710
|
+
raysSpeed: { value: raysSpeed },
|
|
3711
|
+
lightSpread: { value: lightSpread },
|
|
3712
|
+
rayLength: { value: rayLength },
|
|
3713
|
+
pulsating: { value: pulsating ? 1.0 : 0.0 },
|
|
3714
|
+
fadeDistance: { value: fadeDistance },
|
|
3715
|
+
saturation: { value: saturation },
|
|
3716
|
+
mousePos: { value: [0.5, 0.5] },
|
|
3717
|
+
mouseInfluence: { value: mouseInfluence },
|
|
3718
|
+
noiseAmount: { value: noiseAmount },
|
|
3719
|
+
distortion: { value: distortion }
|
|
3720
|
+
};
|
|
3721
|
+
uniformsRef.current = uniforms;
|
|
3722
|
+
|
|
3723
|
+
const geometry = new Triangle(gl);
|
|
3724
|
+
const program = new Program(gl, {
|
|
3725
|
+
vertex: vert,
|
|
3726
|
+
fragment: frag,
|
|
3727
|
+
uniforms
|
|
3728
|
+
});
|
|
3729
|
+
const mesh = new Mesh(gl, { geometry, program });
|
|
3730
|
+
meshRef.current = mesh;
|
|
3731
|
+
|
|
3732
|
+
const updatePlacement = () => {
|
|
3733
|
+
if (!containerRef.current || !renderer) return;
|
|
3734
|
+
|
|
3735
|
+
renderer.dpr = Math.min(window.devicePixelRatio, 2);
|
|
3736
|
+
|
|
3737
|
+
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
|
|
3738
|
+
renderer.setSize(wCSS, hCSS);
|
|
3739
|
+
|
|
3740
|
+
const dpr = renderer.dpr;
|
|
3741
|
+
const w = wCSS * dpr;
|
|
3742
|
+
const h = hCSS * dpr;
|
|
3743
|
+
|
|
3744
|
+
uniforms.iResolution.value = [w, h];
|
|
3745
|
+
|
|
3746
|
+
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h);
|
|
3747
|
+
uniforms.rayPos.value = anchor;
|
|
3748
|
+
uniforms.rayDir.value = dir;
|
|
3749
|
+
};
|
|
3750
|
+
|
|
3751
|
+
const loop = (t: number) => {
|
|
3752
|
+
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
uniforms.iTime.value = t * 0.001;
|
|
3757
|
+
|
|
3758
|
+
if (followMouse && mouseInfluence > 0.0) {
|
|
3759
|
+
const smoothing = 0.92;
|
|
3760
|
+
|
|
3761
|
+
smoothMouseRef.current.x = smoothMouseRef.current.x * smoothing + mouseRef.current.x * (1 - smoothing);
|
|
3762
|
+
smoothMouseRef.current.y = smoothMouseRef.current.y * smoothing + mouseRef.current.y * (1 - smoothing);
|
|
3763
|
+
|
|
3764
|
+
uniforms.mousePos.value = [smoothMouseRef.current.x, smoothMouseRef.current.y];
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
try {
|
|
3768
|
+
renderer.render({ scene: mesh });
|
|
3769
|
+
animationIdRef.current = requestAnimationFrame(loop);
|
|
3770
|
+
} catch (error) {
|
|
3771
|
+
console.warn('WebGL rendering error:', error);
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
|
|
3776
|
+
window.addEventListener('resize', updatePlacement);
|
|
3777
|
+
updatePlacement();
|
|
3778
|
+
animationIdRef.current = requestAnimationFrame(loop);
|
|
3779
|
+
|
|
3780
|
+
cleanupFunctionRef.current = () => {
|
|
3781
|
+
if (animationIdRef.current) {
|
|
3782
|
+
cancelAnimationFrame(animationIdRef.current);
|
|
3783
|
+
animationIdRef.current = null;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
window.removeEventListener('resize', updatePlacement);
|
|
3787
|
+
|
|
3788
|
+
if (renderer) {
|
|
3789
|
+
try {
|
|
3790
|
+
const canvas = renderer.gl.canvas;
|
|
3791
|
+
const loseContextExt = renderer.gl.getExtension('WEBGL_lose_context');
|
|
3792
|
+
if (loseContextExt) {
|
|
3793
|
+
loseContextExt.loseContext();
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
if (canvas && canvas.parentNode) {
|
|
3797
|
+
canvas.parentNode.removeChild(canvas);
|
|
3798
|
+
}
|
|
3799
|
+
} catch (error) {
|
|
3800
|
+
console.warn('Error during WebGL cleanup:', error);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
rendererRef.current = null;
|
|
3805
|
+
uniformsRef.current = null;
|
|
3806
|
+
meshRef.current = null;
|
|
3807
|
+
};
|
|
3808
|
+
};
|
|
3809
|
+
|
|
3810
|
+
initializeWebGL();
|
|
3811
|
+
|
|
3812
|
+
return () => {
|
|
3813
|
+
if (cleanupFunctionRef.current) {
|
|
3814
|
+
cleanupFunctionRef.current();
|
|
3815
|
+
cleanupFunctionRef.current = null;
|
|
3816
|
+
}
|
|
3817
|
+
};
|
|
3818
|
+
}, [
|
|
3819
|
+
isVisible,
|
|
3820
|
+
raysOrigin,
|
|
3821
|
+
raysColor,
|
|
3822
|
+
raysSpeed,
|
|
3823
|
+
lightSpread,
|
|
3824
|
+
rayLength,
|
|
3825
|
+
pulsating,
|
|
3826
|
+
fadeDistance,
|
|
3827
|
+
saturation,
|
|
3828
|
+
followMouse,
|
|
3829
|
+
mouseInfluence,
|
|
3830
|
+
noiseAmount,
|
|
3831
|
+
distortion
|
|
3832
|
+
]);
|
|
3833
|
+
|
|
3834
|
+
useEffect(() => {
|
|
3835
|
+
if (!uniformsRef.current || !containerRef.current || !rendererRef.current) return;
|
|
3836
|
+
|
|
3837
|
+
const u = uniformsRef.current;
|
|
3838
|
+
const renderer = rendererRef.current;
|
|
3839
|
+
|
|
3840
|
+
u.raysColor.value = hexToRgb(raysColor);
|
|
3841
|
+
u.raysSpeed.value = raysSpeed;
|
|
3842
|
+
u.lightSpread.value = lightSpread;
|
|
3843
|
+
u.rayLength.value = rayLength;
|
|
3844
|
+
u.pulsating.value = pulsating ? 1.0 : 0.0;
|
|
3845
|
+
u.fadeDistance.value = fadeDistance;
|
|
3846
|
+
u.saturation.value = saturation;
|
|
3847
|
+
u.mouseInfluence.value = mouseInfluence;
|
|
3848
|
+
u.noiseAmount.value = noiseAmount;
|
|
3849
|
+
u.distortion.value = distortion;
|
|
3850
|
+
|
|
3851
|
+
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
|
|
3852
|
+
const dpr = renderer.dpr;
|
|
3853
|
+
const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr);
|
|
3854
|
+
u.rayPos.value = anchor;
|
|
3855
|
+
u.rayDir.value = dir;
|
|
3856
|
+
}, [
|
|
3857
|
+
raysColor,
|
|
3858
|
+
raysSpeed,
|
|
3859
|
+
lightSpread,
|
|
3860
|
+
raysOrigin,
|
|
3861
|
+
rayLength,
|
|
3862
|
+
pulsating,
|
|
3863
|
+
fadeDistance,
|
|
3864
|
+
saturation,
|
|
3865
|
+
mouseInfluence,
|
|
3866
|
+
noiseAmount,
|
|
3867
|
+
distortion
|
|
3868
|
+
]);
|
|
3869
|
+
|
|
3870
|
+
useEffect(() => {
|
|
3871
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
3872
|
+
if (!containerRef.current || !rendererRef.current) return;
|
|
3873
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
3874
|
+
const x = (e.clientX - rect.left) / rect.width;
|
|
3875
|
+
const y = (e.clientY - rect.top) / rect.height;
|
|
3876
|
+
mouseRef.current = { x, y };
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
if (followMouse) {
|
|
3880
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
3881
|
+
return () => window.removeEventListener('mousemove', handleMouseMove);
|
|
3882
|
+
}
|
|
3883
|
+
}, [followMouse]);
|
|
3884
|
+
|
|
3885
|
+
return (
|
|
3886
|
+
<div
|
|
3887
|
+
ref={containerRef}
|
|
3888
|
+
className={\`w-full h-full pointer-events-none z-[3] overflow-hidden relative \${className}\`.trim()}
|
|
3889
|
+
/>
|
|
3890
|
+
);
|
|
3891
|
+
};
|
|
3892
|
+
|
|
3893
|
+
export default LightRays;
|
|
3894
|
+
`,
|
|
3895
|
+
"components/motion/shiny-text": `"use client";
|
|
3896
|
+
|
|
3897
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
3898
|
+
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
|
|
3899
|
+
|
|
3900
|
+
interface ShinyTextProps {
|
|
3901
|
+
text: string;
|
|
3902
|
+
disabled?: boolean;
|
|
3903
|
+
speed?: number;
|
|
3904
|
+
className?: string;
|
|
3905
|
+
color?: string;
|
|
3906
|
+
shineColor?: string;
|
|
3907
|
+
spread?: number;
|
|
3908
|
+
yoyo?: boolean;
|
|
3909
|
+
pauseOnHover?: boolean;
|
|
3910
|
+
direction?: 'left' | 'right';
|
|
3911
|
+
delay?: number;
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
const ShinyText: React.FC<ShinyTextProps> = ({
|
|
3915
|
+
text,
|
|
3916
|
+
disabled = false,
|
|
3917
|
+
speed = 2,
|
|
3918
|
+
className = '',
|
|
3919
|
+
color = '#b5b5b5',
|
|
3920
|
+
shineColor = '#ffffff',
|
|
3921
|
+
spread = 120,
|
|
3922
|
+
yoyo = false,
|
|
3923
|
+
pauseOnHover = false,
|
|
3924
|
+
direction = 'left',
|
|
3925
|
+
delay = 0
|
|
3926
|
+
}) => {
|
|
3927
|
+
const [isPaused, setIsPaused] = useState(false);
|
|
3928
|
+
const progress = useMotionValue(0);
|
|
3929
|
+
const elapsedRef = useRef(0);
|
|
3930
|
+
const lastTimeRef = useRef<number | null>(null);
|
|
3931
|
+
const directionRef = useRef(direction === 'left' ? 1 : -1);
|
|
3932
|
+
|
|
3933
|
+
const animationDuration = speed * 1000;
|
|
3934
|
+
const delayDuration = delay * 1000;
|
|
3935
|
+
|
|
3936
|
+
useAnimationFrame(time => {
|
|
3937
|
+
if (disabled || isPaused) {
|
|
3938
|
+
lastTimeRef.current = null;
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
if (lastTimeRef.current === null) {
|
|
3943
|
+
lastTimeRef.current = time;
|
|
3944
|
+
return;
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
const deltaTime = time - lastTimeRef.current;
|
|
3948
|
+
lastTimeRef.current = time;
|
|
3949
|
+
|
|
3950
|
+
elapsedRef.current += deltaTime;
|
|
3951
|
+
|
|
3952
|
+
if (yoyo) {
|
|
3953
|
+
const cycleDuration = animationDuration + delayDuration;
|
|
3954
|
+
const fullCycle = cycleDuration * 2;
|
|
3955
|
+
const cycleTime = elapsedRef.current % fullCycle;
|
|
3956
|
+
|
|
3957
|
+
if (cycleTime < animationDuration) {
|
|
3958
|
+
const p = (cycleTime / animationDuration) * 100;
|
|
3959
|
+
progress.set(directionRef.current === 1 ? p : 100 - p);
|
|
3960
|
+
} else if (cycleTime < cycleDuration) {
|
|
3961
|
+
progress.set(directionRef.current === 1 ? 100 : 0);
|
|
3962
|
+
} else if (cycleTime < cycleDuration + animationDuration) {
|
|
3963
|
+
const reverseTime = cycleTime - cycleDuration;
|
|
3964
|
+
const p = 100 - (reverseTime / animationDuration) * 100;
|
|
3965
|
+
progress.set(directionRef.current === 1 ? p : 100 - p);
|
|
3966
|
+
} else {
|
|
3967
|
+
progress.set(directionRef.current === 1 ? 0 : 100);
|
|
3968
|
+
}
|
|
3969
|
+
} else {
|
|
3970
|
+
const cycleDuration = animationDuration + delayDuration;
|
|
3971
|
+
const cycleTime = elapsedRef.current % cycleDuration;
|
|
3972
|
+
|
|
3973
|
+
if (cycleTime < animationDuration) {
|
|
3974
|
+
const p = (cycleTime / animationDuration) * 100;
|
|
3975
|
+
progress.set(directionRef.current === 1 ? p : 100 - p);
|
|
3976
|
+
} else {
|
|
3977
|
+
progress.set(directionRef.current === 1 ? 100 : 0);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
});
|
|
3981
|
+
|
|
3982
|
+
useEffect(() => {
|
|
3983
|
+
directionRef.current = direction === 'left' ? 1 : -1;
|
|
3984
|
+
elapsedRef.current = 0;
|
|
3985
|
+
progress.set(0);
|
|
3986
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3987
|
+
}, [direction]);
|
|
3988
|
+
|
|
3989
|
+
const backgroundPosition = useTransform(progress, p => \`\${150 - p * 2}% center\`);
|
|
3990
|
+
|
|
3991
|
+
const handleMouseEnter = useCallback(() => {
|
|
3992
|
+
if (pauseOnHover) setIsPaused(true);
|
|
3993
|
+
}, [pauseOnHover]);
|
|
3994
|
+
|
|
3995
|
+
const handleMouseLeave = useCallback(() => {
|
|
3996
|
+
if (pauseOnHover) setIsPaused(false);
|
|
3997
|
+
}, [pauseOnHover]);
|
|
3998
|
+
|
|
3999
|
+
const gradientStyle: React.CSSProperties = {
|
|
4000
|
+
backgroundImage: \`linear-gradient(\${spread}deg, \${color} 0%, \${color} 35%, \${shineColor} 50%, \${color} 65%, \${color} 100%)\`,
|
|
4001
|
+
backgroundSize: '200% auto',
|
|
4002
|
+
WebkitBackgroundClip: 'text',
|
|
4003
|
+
backgroundClip: 'text',
|
|
4004
|
+
WebkitTextFillColor: 'transparent'
|
|
4005
|
+
};
|
|
4006
|
+
|
|
4007
|
+
return (
|
|
4008
|
+
<motion.span
|
|
4009
|
+
className={\`inline-block \${className}\`}
|
|
4010
|
+
style={{ ...gradientStyle, backgroundPosition }}
|
|
4011
|
+
onMouseEnter={handleMouseEnter}
|
|
4012
|
+
onMouseLeave={handleMouseLeave}
|
|
4013
|
+
>
|
|
4014
|
+
{text}
|
|
4015
|
+
</motion.span>
|
|
4016
|
+
);
|
|
4017
|
+
};
|
|
4018
|
+
|
|
4019
|
+
export default ShinyText;
|
|
4020
|
+
`,
|
|
4021
|
+
"hooks/use-countdown": `"use client";
|
|
4022
|
+
|
|
4023
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4024
|
+
|
|
4025
|
+
interface TimeLeft {
|
|
4026
|
+
days: number;
|
|
4027
|
+
hours: number;
|
|
4028
|
+
minutes: number;
|
|
4029
|
+
seconds: number;
|
|
4030
|
+
total: number;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
export function useCountdown(targetDate: Date): TimeLeft {
|
|
4034
|
+
const calculateTimeLeft = useCallback((): TimeLeft => {
|
|
4035
|
+
const difference = targetDate.getTime() - new Date().getTime();
|
|
4036
|
+
|
|
4037
|
+
if (difference <= 0) {
|
|
4038
|
+
return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 };
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
return {
|
|
4042
|
+
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
|
4043
|
+
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
|
|
4044
|
+
minutes: Math.floor((difference / 1000 / 60) % 60),
|
|
4045
|
+
seconds: Math.floor((difference / 1000) % 60),
|
|
4046
|
+
total: difference,
|
|
4047
|
+
};
|
|
4048
|
+
}, [targetDate]);
|
|
4049
|
+
|
|
4050
|
+
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calculateTimeLeft);
|
|
4051
|
+
|
|
4052
|
+
useEffect(() => {
|
|
4053
|
+
const timer = setInterval(() => {
|
|
4054
|
+
setTimeLeft(calculateTimeLeft());
|
|
4055
|
+
}, 1000);
|
|
4056
|
+
|
|
4057
|
+
return () => clearInterval(timer);
|
|
4058
|
+
}, [calculateTimeLeft]);
|
|
4059
|
+
|
|
4060
|
+
return timeLeft;
|
|
4061
|
+
}
|
|
4062
|
+
`,
|
|
4063
|
+
"hooks/use-media-query": `"use client";
|
|
4064
|
+
|
|
4065
|
+
import { useState, useEffect } from "react";
|
|
4066
|
+
|
|
4067
|
+
export function useMediaQuery(query: string): boolean {
|
|
4068
|
+
const [matches, setMatches] = useState(false);
|
|
4069
|
+
|
|
4070
|
+
useEffect(() => {
|
|
4071
|
+
const media = window.matchMedia(query);
|
|
4072
|
+
setMatches(media.matches);
|
|
4073
|
+
|
|
4074
|
+
const listener = (event: MediaQueryListEvent) => {
|
|
4075
|
+
setMatches(event.matches);
|
|
4076
|
+
};
|
|
4077
|
+
|
|
4078
|
+
media.addEventListener("change", listener);
|
|
4079
|
+
return () => media.removeEventListener("change", listener);
|
|
4080
|
+
}, [query]);
|
|
4081
|
+
|
|
4082
|
+
return matches;
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
export function useIsMobile(): boolean {
|
|
4086
|
+
return useMediaQuery("(max-width: 767px)");
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
export function useIsTablet(): boolean {
|
|
4090
|
+
return useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
export function useIsDesktop(): boolean {
|
|
4094
|
+
return useMediaQuery("(min-width: 1024px)");
|
|
4095
|
+
}
|
|
4096
|
+
`,
|
|
4097
|
+
"hooks/use-scroll-animation": `"use client";
|
|
4098
|
+
|
|
4099
|
+
import { useEffect, useRef, useState } from "react";
|
|
4100
|
+
|
|
4101
|
+
interface UseScrollAnimationOptions {
|
|
4102
|
+
threshold?: number;
|
|
4103
|
+
rootMargin?: string;
|
|
4104
|
+
triggerOnce?: boolean;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
export function useScrollAnimation<T extends HTMLElement>(
|
|
4108
|
+
options: UseScrollAnimationOptions = {}
|
|
4109
|
+
): [React.RefObject<T | null>, boolean] {
|
|
4110
|
+
const { threshold = 0.1, rootMargin = "0px", triggerOnce = true } = options;
|
|
4111
|
+
const ref = useRef<T | null>(null);
|
|
4112
|
+
const [isInView, setIsInView] = useState(false);
|
|
4113
|
+
|
|
4114
|
+
useEffect(() => {
|
|
4115
|
+
const element = ref.current;
|
|
4116
|
+
if (!element) return;
|
|
4117
|
+
|
|
4118
|
+
const observer = new IntersectionObserver(
|
|
4119
|
+
([entry]) => {
|
|
4120
|
+
if (entry.isIntersecting) {
|
|
4121
|
+
setIsInView(true);
|
|
4122
|
+
if (triggerOnce) {
|
|
4123
|
+
observer.unobserve(element);
|
|
4124
|
+
}
|
|
4125
|
+
} else if (!triggerOnce) {
|
|
4126
|
+
setIsInView(false);
|
|
4127
|
+
}
|
|
4128
|
+
},
|
|
4129
|
+
{ threshold, rootMargin }
|
|
4130
|
+
);
|
|
4131
|
+
|
|
4132
|
+
observer.observe(element);
|
|
4133
|
+
|
|
4134
|
+
return () => observer.disconnect();
|
|
4135
|
+
}, [threshold, rootMargin, triggerOnce]);
|
|
4136
|
+
|
|
4137
|
+
return [ref, isInView];
|
|
4138
|
+
}
|
|
4139
|
+
`,
|
|
4140
|
+
"utils/cn": `import { clsx, type ClassValue } from "clsx";
|
|
4141
|
+
import { twMerge } from "tailwind-merge";
|
|
4142
|
+
|
|
4143
|
+
export function cn(...inputs: ClassValue[]) {
|
|
4144
|
+
return twMerge(clsx(inputs));
|
|
4145
|
+
}
|
|
4146
|
+
`
|
|
4147
|
+
};
|
|
4148
|
+
|
|
4149
|
+
// src/utils/file-writer.ts
|
|
4150
|
+
var ALIAS_MAP = {
|
|
4151
|
+
__COMPONENTS_ALIAS__: "components",
|
|
4152
|
+
__HOOKS_ALIAS__: "hooks",
|
|
4153
|
+
__UTILS_ALIAS__: "utils"
|
|
4154
|
+
};
|
|
4155
|
+
function replaceAliases(content, config) {
|
|
4156
|
+
let result = content;
|
|
4157
|
+
for (const [placeholder, key] of Object.entries(ALIAS_MAP)) {
|
|
4158
|
+
result = result.replaceAll(placeholder, config.aliases[key]);
|
|
4159
|
+
}
|
|
4160
|
+
return result;
|
|
4161
|
+
}
|
|
4162
|
+
function writeTemplateFile(options) {
|
|
4163
|
+
const { templateKey, targetDir, fileName, config, overwrite = false } = options;
|
|
4164
|
+
const template = TEMPLATES[templateKey];
|
|
4165
|
+
if (!template) {
|
|
4166
|
+
throw new Error(`Template not found: ${templateKey}`);
|
|
4167
|
+
}
|
|
4168
|
+
const targetPath = path3.join(targetDir, fileName);
|
|
4169
|
+
if (fs3.existsSync(targetPath) && !overwrite) {
|
|
4170
|
+
return { written: false, path: targetPath };
|
|
4171
|
+
}
|
|
4172
|
+
fs3.ensureDirSync(targetDir);
|
|
4173
|
+
const content = replaceAliases(template, config);
|
|
4174
|
+
fs3.writeFileSync(targetPath, content, "utf-8");
|
|
4175
|
+
return { written: true, path: targetPath };
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
// src/utils/css-injector.ts
|
|
4179
|
+
import fs4 from "fs-extra";
|
|
4180
|
+
var MARKER_START = (name) => `/* glintkit:${name} - start */`;
|
|
4181
|
+
var MARKER_END = (name) => `/* glintkit:${name} - end */`;
|
|
4182
|
+
function injectCSSPreset(cssFilePath, presetName, presetContent) {
|
|
4183
|
+
if (!fs4.existsSync(cssFilePath)) {
|
|
4184
|
+
logger.warn(`CSS file not found: ${cssFilePath}. Skipping CSS injection.`);
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
let css = fs4.readFileSync(cssFilePath, "utf-8");
|
|
4188
|
+
const startMarker = MARKER_START(presetName);
|
|
4189
|
+
const endMarker = MARKER_END(presetName);
|
|
4190
|
+
if (css.includes(startMarker)) {
|
|
4191
|
+
const startIdx = css.indexOf(startMarker);
|
|
4192
|
+
const endIdx = css.indexOf(endMarker);
|
|
4193
|
+
if (endIdx !== -1) {
|
|
4194
|
+
css = css.slice(0, startIdx) + startMarker + "\n" + presetContent + "\n" + endMarker + css.slice(endIdx + endMarker.length);
|
|
4195
|
+
fs4.writeFileSync(cssFilePath, css, "utf-8");
|
|
4196
|
+
return;
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
const block = `
|
|
4200
|
+
${startMarker}
|
|
4201
|
+
${presetContent}
|
|
4202
|
+
${endMarker}
|
|
4203
|
+
`;
|
|
4204
|
+
css += block;
|
|
4205
|
+
fs4.writeFileSync(cssFilePath, css, "utf-8");
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
// src/registry/css-presets.ts
|
|
4209
|
+
var CSS_PRESETS = {
|
|
4210
|
+
glass: `:root {
|
|
4211
|
+
--glass-bg: rgba(255, 255, 255, 0.06);
|
|
4212
|
+
--glass-bg-strong: rgba(255, 255, 255, 0.1);
|
|
4213
|
+
--glass-border: rgba(255, 255, 255, 0.1);
|
|
4214
|
+
--glass-shadow: rgba(0, 0, 0, 0.2);
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
.glass {
|
|
4218
|
+
background: var(--glass-bg);
|
|
4219
|
+
backdrop-filter: blur(10px);
|
|
4220
|
+
-webkit-backdrop-filter: blur(10px);
|
|
4221
|
+
border: 1px solid var(--glass-border);
|
|
4222
|
+
border-radius: 16px;
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
.glass-strong {
|
|
4226
|
+
background: var(--glass-bg-strong);
|
|
4227
|
+
backdrop-filter: blur(20px);
|
|
4228
|
+
-webkit-backdrop-filter: blur(20px);
|
|
4229
|
+
border: 1px solid var(--glass-border);
|
|
4230
|
+
border-radius: 1rem;
|
|
4231
|
+
box-shadow: 0 8px 32px var(--glass-shadow);
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
.glass-gradient {
|
|
4235
|
+
background: linear-gradient(135deg, rgba(0, 212, 255, 0.08), rgba(0, 255, 136, 0.05));
|
|
4236
|
+
backdrop-filter: blur(20px);
|
|
4237
|
+
-webkit-backdrop-filter: blur(20px);
|
|
4238
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
4239
|
+
border-radius: 1rem;
|
|
4240
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
@media (max-width: 768px) {
|
|
4244
|
+
.glass {
|
|
4245
|
+
backdrop-filter: blur(6px);
|
|
4246
|
+
-webkit-backdrop-filter: blur(6px);
|
|
4247
|
+
}
|
|
4248
|
+
.glass-strong {
|
|
4249
|
+
backdrop-filter: blur(10px);
|
|
4250
|
+
-webkit-backdrop-filter: blur(10px);
|
|
4251
|
+
}
|
|
4252
|
+
.glass-gradient {
|
|
4253
|
+
backdrop-filter: blur(10px);
|
|
4254
|
+
-webkit-backdrop-filter: blur(10px);
|
|
4255
|
+
}
|
|
4256
|
+
}`,
|
|
4257
|
+
"gradient-text": `.gradient-text {
|
|
4258
|
+
background: linear-gradient(135deg, #00D4FF, #00FF88);
|
|
4259
|
+
-webkit-background-clip: text;
|
|
4260
|
+
-webkit-text-fill-color: transparent;
|
|
4261
|
+
background-clip: text;
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
@keyframes text-flow {
|
|
4265
|
+
0% { background-position: 0% 50%; }
|
|
4266
|
+
100% { background-position: 200% 50%; }
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
.gradient-text-animated {
|
|
4270
|
+
background: linear-gradient(90deg, #00D4FF, #00FF88, #00D4FF, #00FF88);
|
|
4271
|
+
background-size: 200% 100%;
|
|
4272
|
+
-webkit-background-clip: text;
|
|
4273
|
+
-webkit-text-fill-color: transparent;
|
|
4274
|
+
background-clip: text;
|
|
4275
|
+
animation: text-flow 3s linear infinite;
|
|
4276
|
+
}`,
|
|
4277
|
+
"glow-border": `@keyframes border-flow {
|
|
4278
|
+
0% { background-position: 0% 50%; }
|
|
4279
|
+
100% { background-position: 200% 50%; }
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
.btn-glow-border {
|
|
4283
|
+
position: relative;
|
|
4284
|
+
background: var(--background, #000);
|
|
4285
|
+
z-index: 1;
|
|
4286
|
+
}
|
|
4287
|
+
|
|
4288
|
+
.btn-glow-border::before {
|
|
4289
|
+
content: '';
|
|
4290
|
+
position: absolute;
|
|
4291
|
+
inset: -2px;
|
|
4292
|
+
border-radius: 9999px;
|
|
4293
|
+
background: linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.1), #00D4FF, #00FF88, rgba(255,255,255,0.1), rgba(255,255,255,0.1));
|
|
4294
|
+
background-size: 200% 100%;
|
|
4295
|
+
z-index: -2;
|
|
4296
|
+
animation: border-flow 3s linear infinite;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
.btn-glow-border::after {
|
|
4300
|
+
content: '';
|
|
4301
|
+
position: absolute;
|
|
4302
|
+
inset: 1px;
|
|
4303
|
+
border-radius: 9999px;
|
|
4304
|
+
background: var(--background, #000);
|
|
4305
|
+
z-index: -1;
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
.btn-glow-border:hover::before {
|
|
4309
|
+
animation-duration: 1.5s;
|
|
4310
|
+
filter: blur(3px);
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
.btn-glow-border:hover {
|
|
4314
|
+
box-shadow: 0 0 30px rgba(0, 212, 255, 0.4), 0 0 60px rgba(0, 255, 136, 0.2);
|
|
4315
|
+
}`,
|
|
4316
|
+
animations: `@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
|
4317
|
+
@keyframes pulse-glow { 0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3), 0 0 40px rgba(0, 255, 136, 0.2); } 50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.6), 0 0 60px rgba(0, 255, 136, 0.4); } }
|
|
4318
|
+
@keyframes slide-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
|
4319
|
+
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
4320
|
+
@keyframes soundwave { 0%, 100% { height: 4px; } 50% { height: 16px; } }
|
|
4321
|
+
@keyframes attention-ring { 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4), 0 0 0 0 rgba(0, 255, 136, 0.3); } 50% { box-shadow: 0 0 0 6px rgba(0, 212, 255, 0), 0 0 0 12px rgba(0, 255, 136, 0); } }
|
|
4322
|
+
@keyframes countdown-shake { 0%, 100% { transform: translateX(0); } 10%, 30%, 50%, 70%, 90% { transform: translateX(-1px); } 20%, 40%, 60%, 80% { transform: translateX(1px); } }
|
|
4323
|
+
|
|
4324
|
+
.animate-float { animation: float 3s ease-in-out infinite; }
|
|
4325
|
+
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
|
4326
|
+
.animate-slide-up { animation: slide-up 0.6s ease-out forwards; }
|
|
4327
|
+
.animate-fade-in { animation: fade-in 0.6s ease-out forwards; }
|
|
4328
|
+
.animate-soundwave { animation: soundwave 0.6s ease-in-out infinite; }
|
|
4329
|
+
.animate-attention-ring { animation: attention-ring 2s ease-in-out infinite; }
|
|
4330
|
+
.animate-shake { animation: countdown-shake 0.5s ease-in-out infinite; }`,
|
|
4331
|
+
"holo-card": `/* Overlay Background */
|
|
4332
|
+
.holo-card-overlay {
|
|
4333
|
+
position: fixed;
|
|
4334
|
+
inset: 0;
|
|
4335
|
+
z-index: 100;
|
|
4336
|
+
display: flex;
|
|
4337
|
+
align-items: center;
|
|
4338
|
+
justify-content: center;
|
|
4339
|
+
background: rgba(0, 0, 0, 0.85);
|
|
4340
|
+
backdrop-filter: blur(12px);
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
/* Card Container - 3D \uACF5\uAC04 \uC124\uC815 */
|
|
4344
|
+
.holo-card-container {
|
|
4345
|
+
perspective: 600px;
|
|
4346
|
+
transform-origin: center;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
/* Card Rotator - 3D \uD68C\uC804 + \uAE00\uB85C\uC6B0 */
|
|
4350
|
+
.holo-card {
|
|
4351
|
+
--glow: #69d1e9;
|
|
4352
|
+
--mx: 50%;
|
|
4353
|
+
--my: 50%;
|
|
4354
|
+
--rx: 0deg;
|
|
4355
|
+
--ry: 0deg;
|
|
4356
|
+
--posx: 50%;
|
|
4357
|
+
--posy: 50%;
|
|
4358
|
+
--hyp: 0.5;
|
|
4359
|
+
--o: 1;
|
|
4360
|
+
|
|
4361
|
+
position: relative;
|
|
4362
|
+
width: 320px;
|
|
4363
|
+
max-width: 85vw;
|
|
4364
|
+
aspect-ratio: 2.5/3.5;
|
|
4365
|
+
border-radius: 16px;
|
|
4366
|
+
-webkit-transform-style: preserve-3d;
|
|
4367
|
+
transform-style: preserve-3d;
|
|
4368
|
+
-webkit-transform: rotateY(var(--rx)) rotateX(var(--ry));
|
|
4369
|
+
transform: rotateY(var(--rx)) rotateX(var(--ry));
|
|
4370
|
+
transition: transform 0.15s ease-out;
|
|
4371
|
+
will-change: transform;
|
|
4372
|
+
|
|
4373
|
+
/* Active glow effect */
|
|
4374
|
+
box-shadow:
|
|
4375
|
+
0 0 10px 0px var(--glow),
|
|
4376
|
+
0 0 10px 0px var(--glow),
|
|
4377
|
+
0 0 30px 0px var(--glow),
|
|
4378
|
+
0px 10px 20px -5px rgba(0, 0, 0, 0.5);
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
@media (min-width: 640px) {
|
|
4382
|
+
.holo-card {
|
|
4383
|
+
width: 360px;
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
@media (min-width: 768px) {
|
|
4388
|
+
.holo-card {
|
|
4389
|
+
width: 400px;
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
/* Card Content Layer */
|
|
4394
|
+
.holo-card__content {
|
|
4395
|
+
position: relative;
|
|
4396
|
+
z-index: 10;
|
|
4397
|
+
border-radius: 16px;
|
|
4398
|
+
overflow: hidden;
|
|
4399
|
+
background: linear-gradient(135deg, rgba(20, 20, 30, 0.95), rgba(10, 10, 20, 0.98));
|
|
4400
|
+
width: 100%;
|
|
4401
|
+
height: 100%;
|
|
4402
|
+
/* Fix for iOS Safari - ensure content is visible */
|
|
4403
|
+
-webkit-transform: translateZ(0);
|
|
4404
|
+
transform: translateZ(0);
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
/* Inner Frame Border - Pokemon Card Style */
|
|
4408
|
+
.holo-card__inner-frame {
|
|
4409
|
+
--frame-color: #C0C0C0;
|
|
4410
|
+
|
|
4411
|
+
position: absolute;
|
|
4412
|
+
inset: 0;
|
|
4413
|
+
border-radius: 16px;
|
|
4414
|
+
pointer-events: none;
|
|
4415
|
+
z-index: 50;
|
|
4416
|
+
|
|
4417
|
+
/* Thick inner border - inset shadow draws inside */
|
|
4418
|
+
box-shadow:
|
|
4419
|
+
inset 0 0 0 10px var(--frame-color),
|
|
4420
|
+
inset 0 0 20px 5px var(--frame-color);
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
@keyframes frame-shimmer {
|
|
4424
|
+
0%, 100% {
|
|
4425
|
+
background-position: -200% 0;
|
|
4426
|
+
}
|
|
4427
|
+
50% {
|
|
4428
|
+
background-position: 200% 0;
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
|
|
4432
|
+
/* Shine Layer - \uD640\uB85C\uADF8\uB798\uD53D \uBB34\uC9C0\uAC1C \uD6A8\uACFC */
|
|
4433
|
+
.holo-card__shine {
|
|
4434
|
+
--space: 5%;
|
|
4435
|
+
--angle: 133deg;
|
|
4436
|
+
|
|
4437
|
+
position: absolute;
|
|
4438
|
+
inset: 0;
|
|
4439
|
+
border-radius: 16px;
|
|
4440
|
+
pointer-events: none;
|
|
4441
|
+
z-index: 20;
|
|
4442
|
+
|
|
4443
|
+
background-image:
|
|
4444
|
+
/* Rainbow gradient */
|
|
4445
|
+
repeating-linear-gradient(
|
|
4446
|
+
0deg,
|
|
4447
|
+
rgb(255, 119, 115) calc(var(--space) * 1),
|
|
4448
|
+
rgba(255, 237, 95, 1) calc(var(--space) * 2),
|
|
4449
|
+
rgba(168, 255, 95, 1) calc(var(--space) * 3),
|
|
4450
|
+
rgba(131, 255, 247, 1) calc(var(--space) * 4),
|
|
4451
|
+
rgba(120, 148, 255, 1) calc(var(--space) * 5),
|
|
4452
|
+
rgb(216, 117, 255) calc(var(--space) * 6),
|
|
4453
|
+
rgb(255, 119, 115) calc(var(--space) * 7)
|
|
4454
|
+
),
|
|
4455
|
+
/* Diagonal shine lines */
|
|
4456
|
+
repeating-linear-gradient(
|
|
4457
|
+
var(--angle),
|
|
4458
|
+
#0e152e 0%,
|
|
4459
|
+
hsl(180, 10%, 60%) 3.8%,
|
|
4460
|
+
hsl(180, 29%, 66%) 4.5%,
|
|
4461
|
+
hsl(180, 10%, 60%) 5.2%,
|
|
4462
|
+
#0e152e 10%,
|
|
4463
|
+
#0e152e 12%
|
|
4464
|
+
),
|
|
4465
|
+
/* Radial highlight following mouse */
|
|
4466
|
+
radial-gradient(
|
|
4467
|
+
farthest-corner circle at var(--mx) var(--my),
|
|
4468
|
+
rgba(255, 255, 255, 0.8) 10%,
|
|
4469
|
+
rgba(200, 200, 200, 0.3) 20%,
|
|
4470
|
+
rgba(0, 0, 0, 0.5) 90%
|
|
4471
|
+
);
|
|
4472
|
+
|
|
4473
|
+
background-blend-mode: hue, hard-light, normal;
|
|
4474
|
+
background-size: 200% 700%, 300% 300%, 200% 200%;
|
|
4475
|
+
background-position:
|
|
4476
|
+
0% var(--posy),
|
|
4477
|
+
var(--posx) var(--posy),
|
|
4478
|
+
var(--posx) var(--posy);
|
|
4479
|
+
|
|
4480
|
+
filter: brightness(calc((var(--hyp) * 0.3) + 0.5)) contrast(2.5) saturate(0.65);
|
|
4481
|
+
mix-blend-mode: color-dodge;
|
|
4482
|
+
opacity: var(--o);
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
/* Glare Layer - \uBE5B \uBC18\uC0AC \uD6A8\uACFC */
|
|
4486
|
+
.holo-card__glare {
|
|
4487
|
+
position: absolute;
|
|
4488
|
+
inset: 0;
|
|
4489
|
+
border-radius: 16px;
|
|
4490
|
+
pointer-events: none;
|
|
4491
|
+
z-index: 25;
|
|
4492
|
+
|
|
4493
|
+
background-image: radial-gradient(
|
|
4494
|
+
farthest-corner circle at var(--mx) var(--my),
|
|
4495
|
+
rgba(255, 255, 255, 0.8) 0%,
|
|
4496
|
+
rgba(255, 255, 255, 0.4) 15%,
|
|
4497
|
+
rgba(0, 0, 0, 0.5) 50%,
|
|
4498
|
+
rgba(0, 0, 0, 0.7) 90%
|
|
4499
|
+
);
|
|
4500
|
+
|
|
4501
|
+
mix-blend-mode: overlay;
|
|
4502
|
+
opacity: calc(var(--o) * 0.7);
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
/* Card flip animation - \uCE74\uB4DC \uB4A4\uC9D1\uAE30 \uD6A8\uACFC */
|
|
4506
|
+
@keyframes holo-card-flip-in {
|
|
4507
|
+
0% {
|
|
4508
|
+
transform: rotateY(180deg) scale(0.6);
|
|
4509
|
+
}
|
|
4510
|
+
100% {
|
|
4511
|
+
transform: rotateY(0deg) scale(1);
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
@keyframes holo-card-flip-out {
|
|
4516
|
+
0% {
|
|
4517
|
+
transform: rotateY(0deg) scale(1);
|
|
4518
|
+
}
|
|
4519
|
+
100% {
|
|
4520
|
+
transform: rotateY(-180deg) scale(0.6);
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
/* Animation states */
|
|
4525
|
+
.holo-card--entering {
|
|
4526
|
+
animation: holo-card-flip-in 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
.holo-card--exiting {
|
|
4530
|
+
animation: holo-card-flip-out 0.6s ease-in-out !important;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
/* Card back side - \uCE74\uB4DC \uB4B7\uBA74 */
|
|
4534
|
+
.holo-card__back {
|
|
4535
|
+
position: absolute;
|
|
4536
|
+
inset: 0;
|
|
4537
|
+
width: 100%;
|
|
4538
|
+
height: 100%;
|
|
4539
|
+
border-radius: 16px;
|
|
4540
|
+
backface-visibility: hidden;
|
|
4541
|
+
-webkit-backface-visibility: hidden;
|
|
4542
|
+
-webkit-transform: rotateY(180deg) translateZ(0);
|
|
4543
|
+
transform: rotateY(180deg) translateZ(0);
|
|
4544
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f0f23 100%);
|
|
4545
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
4546
|
+
display: flex;
|
|
4547
|
+
align-items: center;
|
|
4548
|
+
justify-content: center;
|
|
4549
|
+
overflow: hidden;
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
/* Back pattern - \uD640\uB85C\uADF8\uB798\uD53D \uD328\uD134 */
|
|
4553
|
+
.holo-card__back::before {
|
|
4554
|
+
content: '';
|
|
4555
|
+
position: absolute;
|
|
4556
|
+
inset: 0;
|
|
4557
|
+
background:
|
|
4558
|
+
repeating-linear-gradient(
|
|
4559
|
+
45deg,
|
|
4560
|
+
transparent,
|
|
4561
|
+
transparent 10px,
|
|
4562
|
+
rgba(0, 212, 255, 0.03) 10px,
|
|
4563
|
+
rgba(0, 212, 255, 0.03) 20px
|
|
4564
|
+
),
|
|
4565
|
+
repeating-linear-gradient(
|
|
4566
|
+
-45deg,
|
|
4567
|
+
transparent,
|
|
4568
|
+
transparent 10px,
|
|
4569
|
+
rgba(0, 255, 136, 0.03) 10px,
|
|
4570
|
+
rgba(0, 255, 136, 0.03) 20px
|
|
4571
|
+
);
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
/* Back logo/text */
|
|
4575
|
+
.holo-card__back-content {
|
|
4576
|
+
position: relative;
|
|
4577
|
+
z-index: 1;
|
|
4578
|
+
text-align: center;
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
.holo-card__back-logo {
|
|
4582
|
+
font-size: 3rem;
|
|
4583
|
+
font-weight: bold;
|
|
4584
|
+
background: linear-gradient(135deg, #00D4FF, #00FF88);
|
|
4585
|
+
-webkit-background-clip: text;
|
|
4586
|
+
-webkit-text-fill-color: transparent;
|
|
4587
|
+
background-clip: text;
|
|
4588
|
+
opacity: 0.8;
|
|
4589
|
+
letter-spacing: 0.1em;
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
.holo-card__back-subtitle {
|
|
4593
|
+
font-size: 0.75rem;
|
|
4594
|
+
color: rgba(255, 255, 255, 0.4);
|
|
4595
|
+
margin-top: 0.5rem;
|
|
4596
|
+
letter-spacing: 0.2em;
|
|
4597
|
+
text-transform: uppercase;
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
/* Front side needs backface-visibility */
|
|
4601
|
+
.holo-card__front {
|
|
4602
|
+
position: absolute;
|
|
4603
|
+
inset: 0;
|
|
4604
|
+
width: 100%;
|
|
4605
|
+
height: 100%;
|
|
4606
|
+
backface-visibility: hidden;
|
|
4607
|
+
-webkit-backface-visibility: hidden;
|
|
4608
|
+
border-radius: 16px;
|
|
4609
|
+
/* Fix iOS Safari rendering issue */
|
|
4610
|
+
-webkit-transform: translateZ(1px);
|
|
4611
|
+
transform: translateZ(1px);
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
/* Overlay fade */
|
|
4615
|
+
@keyframes holo-overlay-in {
|
|
4616
|
+
from { opacity: 0; }
|
|
4617
|
+
to { opacity: 1; }
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
@keyframes holo-overlay-out {
|
|
4621
|
+
from { opacity: 1; }
|
|
4622
|
+
to { opacity: 0; }
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
.holo-card-overlay--entering {
|
|
4626
|
+
animation: holo-overlay-in 0.3s ease-out forwards;
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4629
|
+
.holo-card-overlay--exiting {
|
|
4630
|
+
animation: holo-overlay-out 0.3s ease-in forwards;
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
/* Accessibility: Reduce motion for users who prefer it */
|
|
4634
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4635
|
+
.holo-card {
|
|
4636
|
+
transition: none !important;
|
|
4637
|
+
}
|
|
4638
|
+
.holo-card--entering,
|
|
4639
|
+
.holo-card--exiting {
|
|
4640
|
+
animation: none !important;
|
|
4641
|
+
}
|
|
4642
|
+
.holo-card-overlay--entering,
|
|
4643
|
+
.holo-card-overlay--exiting {
|
|
4644
|
+
animation: none !important;
|
|
4645
|
+
}
|
|
4646
|
+
}`
|
|
4647
|
+
};
|
|
4648
|
+
|
|
4649
|
+
// src/cli/commands/add.ts
|
|
4650
|
+
function resolveAllDependencies(names) {
|
|
4651
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
4652
|
+
const queue = [...names];
|
|
4653
|
+
while (queue.length > 0) {
|
|
4654
|
+
const name = queue.shift();
|
|
4655
|
+
if (resolved.has(name)) continue;
|
|
4656
|
+
const comp = getComponent(name);
|
|
4657
|
+
if (!comp) {
|
|
4658
|
+
logger.warn(`Component "${name}" not found in registry. Skipping.`);
|
|
4659
|
+
continue;
|
|
4660
|
+
}
|
|
4661
|
+
resolved.set(name, comp);
|
|
4662
|
+
for (const dep of comp.registryDependencies) {
|
|
4663
|
+
if (!resolved.has(dep)) {
|
|
4664
|
+
queue.push(dep);
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
}
|
|
4668
|
+
return Array.from(resolved.values());
|
|
4669
|
+
}
|
|
4670
|
+
function resolveTargetDir(config, fileType, cwd) {
|
|
4671
|
+
const aliasMap = {
|
|
4672
|
+
component: config.aliases.components,
|
|
4673
|
+
hook: config.aliases.hooks,
|
|
4674
|
+
util: config.aliases.utils
|
|
4675
|
+
};
|
|
4676
|
+
const alias = aliasMap[fileType];
|
|
4677
|
+
if (!alias) return path4.join(cwd, "src", "components", "ui");
|
|
4678
|
+
const match = alias.match(/^@\/(.*)/);
|
|
4679
|
+
if (match) {
|
|
4680
|
+
return path4.join(cwd, "src", match[1]);
|
|
4681
|
+
}
|
|
4682
|
+
const match2 = alias.match(/^~\/(.*)/);
|
|
4683
|
+
if (match2) {
|
|
4684
|
+
return path4.join(cwd, match2[1]);
|
|
4685
|
+
}
|
|
4686
|
+
return path4.join(cwd, alias);
|
|
4687
|
+
}
|
|
4688
|
+
var addCommand = new Command2("add").description("Add components to your project").argument("[components...]", "Component names to add").option("-c, --category <category>", "Add all components in a category").option("-a, --all", "Add all components").option("-y, --yes", "Skip confirmation prompt").option("--overwrite", "Overwrite existing files").action(async (componentNames, options) => {
|
|
4689
|
+
const cwd = process.cwd();
|
|
4690
|
+
if (!configExists(cwd)) {
|
|
4691
|
+
logger.warn("glintkit.json not found. Running init first...");
|
|
4692
|
+
const { execSync: execSync2 } = await import("child_process");
|
|
4693
|
+
execSync2("npx glintkit init -y", { cwd, stdio: "inherit" });
|
|
4694
|
+
}
|
|
4695
|
+
const config = readConfig(cwd);
|
|
4696
|
+
let requestedNames = [];
|
|
4697
|
+
if (options.all) {
|
|
4698
|
+
requestedNames = REGISTRY.map((c) => c.name);
|
|
4699
|
+
} else if (options.category) {
|
|
4700
|
+
const cats = options.category.split(",");
|
|
4701
|
+
for (const cat of cats) {
|
|
4702
|
+
const comps = getComponentsByCategory(cat.trim());
|
|
4703
|
+
requestedNames.push(...comps.map((c) => c.name));
|
|
4704
|
+
}
|
|
4705
|
+
if (requestedNames.length === 0) {
|
|
4706
|
+
logger.error(`No components found in category "${options.category}"`);
|
|
4707
|
+
logger.info(`Available categories: ${getAllCategories().join(", ")}`);
|
|
4708
|
+
process.exit(1);
|
|
4709
|
+
}
|
|
4710
|
+
} else if (componentNames.length > 0) {
|
|
4711
|
+
requestedNames = componentNames;
|
|
4712
|
+
} else {
|
|
4713
|
+
const response = await prompts2({
|
|
4714
|
+
type: "multiselect",
|
|
4715
|
+
name: "components",
|
|
4716
|
+
message: "Select components to add:",
|
|
4717
|
+
choices: REGISTRY.map((c) => ({
|
|
4718
|
+
title: `${c.name} ${pc2.dim(`(${c.category})`)}`,
|
|
4719
|
+
value: c.name,
|
|
4720
|
+
description: c.description
|
|
4721
|
+
}))
|
|
4722
|
+
});
|
|
4723
|
+
if (!response.components || response.components.length === 0) {
|
|
4724
|
+
logger.error("No components selected.");
|
|
4725
|
+
process.exit(1);
|
|
4726
|
+
}
|
|
4727
|
+
requestedNames = response.components;
|
|
4728
|
+
}
|
|
4729
|
+
for (const name of requestedNames) {
|
|
4730
|
+
if (!getComponent(name)) {
|
|
4731
|
+
logger.error(`Component "${name}" not found.`);
|
|
4732
|
+
logger.info("Run `glintkit list` to see available components.");
|
|
4733
|
+
process.exit(1);
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
const allComponents = resolveAllDependencies(requestedNames);
|
|
4737
|
+
const npmDeps = [...new Set(allComponents.flatMap((c) => c.npmDependencies))];
|
|
4738
|
+
const cssPresets = [...new Set(allComponents.flatMap((c) => c.cssPresets))];
|
|
4739
|
+
logger.title("\n Components to install:\n");
|
|
4740
|
+
for (const comp of allComponents) {
|
|
4741
|
+
const isRequested = requestedNames.includes(comp.name);
|
|
4742
|
+
const marker = isRequested ? pc2.green("\u25CF") : pc2.dim("\u25CB");
|
|
4743
|
+
console.log(` ${marker} ${comp.name} ${!isRequested ? pc2.dim("(dependency)") : ""}`);
|
|
4744
|
+
}
|
|
4745
|
+
if (npmDeps.length > 0) {
|
|
4746
|
+
logger.break();
|
|
4747
|
+
logger.info(`npm dependencies: ${npmDeps.join(", ")}`);
|
|
4748
|
+
}
|
|
4749
|
+
if (cssPresets.length > 0) {
|
|
4750
|
+
logger.info(`CSS presets: ${cssPresets.join(", ")}`);
|
|
4751
|
+
}
|
|
4752
|
+
if (!options.yes) {
|
|
4753
|
+
logger.break();
|
|
4754
|
+
const confirm = await prompts2({
|
|
4755
|
+
type: "confirm",
|
|
4756
|
+
name: "proceed",
|
|
4757
|
+
message: "Proceed with installation?",
|
|
4758
|
+
initial: true
|
|
4759
|
+
});
|
|
4760
|
+
if (!confirm.proceed) {
|
|
4761
|
+
logger.error("Cancelled.");
|
|
4762
|
+
process.exit(0);
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
const spinner = ora("Installing components...").start();
|
|
4766
|
+
let filesWritten = 0;
|
|
4767
|
+
let filesSkipped = 0;
|
|
4768
|
+
for (const comp of allComponents) {
|
|
4769
|
+
for (const file of comp.files) {
|
|
4770
|
+
const targetDir = resolveTargetDir(config, file.type, cwd);
|
|
4771
|
+
const result = writeTemplateFile({
|
|
4772
|
+
templateKey: file.templateKey,
|
|
4773
|
+
targetDir,
|
|
4774
|
+
fileName: file.fileName,
|
|
4775
|
+
config,
|
|
4776
|
+
overwrite: options.overwrite ?? false
|
|
4777
|
+
});
|
|
4778
|
+
if (result.written) {
|
|
4779
|
+
filesWritten++;
|
|
4780
|
+
} else {
|
|
4781
|
+
filesSkipped++;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
spinner.succeed(`${filesWritten} file(s) written${filesSkipped > 0 ? `, ${filesSkipped} skipped (already exist)` : ""}`);
|
|
4786
|
+
if (npmDeps.length > 0) {
|
|
4787
|
+
const pmSpinner = ora("Installing npm dependencies...").start();
|
|
4788
|
+
try {
|
|
4789
|
+
const pm = detectPackageManager(cwd);
|
|
4790
|
+
installDependencies(npmDeps, pm, cwd);
|
|
4791
|
+
pmSpinner.succeed(`Installed: ${npmDeps.join(", ")}`);
|
|
4792
|
+
} catch (error) {
|
|
4793
|
+
pmSpinner.fail(`Failed to install dependencies: ${error.message}`);
|
|
4794
|
+
logger.info(`You can manually install: ${npmDeps.join(" ")}`);
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
4797
|
+
if (cssPresets.length > 0) {
|
|
4798
|
+
const cssSpinner = ora("Injecting CSS presets...").start();
|
|
4799
|
+
const cssPath = path4.join(cwd, config.tailwind.css);
|
|
4800
|
+
for (const presetName of cssPresets) {
|
|
4801
|
+
const presetContent = CSS_PRESETS[presetName];
|
|
4802
|
+
if (presetContent) {
|
|
4803
|
+
injectCSSPreset(cssPath, presetName, presetContent);
|
|
4804
|
+
} else {
|
|
4805
|
+
logger.warn(`CSS preset "${presetName}" not found.`);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
cssSpinner.succeed(`Injected ${cssPresets.length} CSS preset(s)`);
|
|
4809
|
+
}
|
|
4810
|
+
logger.break();
|
|
4811
|
+
logger.success("Done! Components are ready to use.");
|
|
4812
|
+
if (filesSkipped > 0) {
|
|
4813
|
+
logger.info(`Use --overwrite to replace existing files.`);
|
|
4814
|
+
}
|
|
4815
|
+
logger.break();
|
|
4816
|
+
});
|
|
4817
|
+
|
|
4818
|
+
// src/cli/commands/list.ts
|
|
4819
|
+
import { Command as Command3 } from "commander";
|
|
4820
|
+
import pc3 from "picocolors";
|
|
4821
|
+
var listCommand = new Command3("list").description("List all available components").option("-c, --category <category>", "Filter by category").action((options) => {
|
|
4822
|
+
logger.title("\n glintkit components\n");
|
|
4823
|
+
const categories = options.category ? [options.category] : getAllCategories();
|
|
4824
|
+
for (const category of categories) {
|
|
4825
|
+
const components = getComponentsByCategory(category);
|
|
4826
|
+
if (components.length === 0) continue;
|
|
4827
|
+
console.log(pc3.bold(pc3.yellow(` ${category.toUpperCase()}`)));
|
|
4828
|
+
for (const comp of components) {
|
|
4829
|
+
const deps = comp.npmDependencies.length > 0 ? pc3.dim(` (${comp.npmDependencies.join(", ")})`) : "";
|
|
4830
|
+
console.log(` ${pc3.green(comp.name)}${deps}`);
|
|
4831
|
+
console.log(` ${pc3.dim(comp.description)}`);
|
|
4832
|
+
}
|
|
4833
|
+
console.log();
|
|
4834
|
+
}
|
|
4835
|
+
console.log(pc3.dim(` Total: ${REGISTRY.length} components
|
|
4836
|
+
`));
|
|
4837
|
+
});
|
|
4838
|
+
|
|
4839
|
+
// src/cli/index.ts
|
|
4840
|
+
var program = new Command4();
|
|
4841
|
+
program.name("glintkit").description("Add beautiful 3D, glass, and motion components to your project").version("0.1.0");
|
|
4842
|
+
program.addCommand(initCommand);
|
|
4843
|
+
program.addCommand(addCommand);
|
|
4844
|
+
program.addCommand(listCommand);
|
|
4845
|
+
program.parse();
|
|
4846
|
+
//# sourceMappingURL=index.js.map
|