create-velocity-astro 1.0.5 → 1.0.7
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/package.json +1 -1
- package/templates/i18n/src/components/landing/CTA.astro +86 -0
- package/templates/i18n/src/components/landing/Credibility.astro +192 -0
- package/templates/i18n/src/components/landing/FeatureTabs.tsx +469 -0
- package/templates/i18n/src/components/landing/Hero.astro +89 -0
- package/templates/i18n/src/components/landing/LandingFooter.astro +49 -0
- package/templates/i18n/src/components/landing/TechStack.astro +53 -0
- package/templates/i18n/src/i18n/translations/en.ts +138 -4
- package/templates/i18n/src/i18n/translations/es.ts +138 -4
- package/templates/i18n/src/i18n/translations/fr.ts +138 -4
- package/templates/i18n/src/layouts/LandingLayout.astro +51 -0
- package/templates/i18n/src/pages/[lang]/index.astro +8 -7
- package/templates/i18n/src/pages/index.astro +29 -0
- /package/templates/i18n/src/content/blog/es/{bienvenido-a-velocity.mdx → welcome-to-velocity.mdx} +0 -0
- /package/templates/i18n/src/content/blog/fr/{bienvenue-sur-velocity.mdx → welcome-to-velocity.mdx} +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Palette,
|
|
4
|
+
Search,
|
|
5
|
+
Zap,
|
|
6
|
+
LayoutGrid,
|
|
7
|
+
Globe,
|
|
8
|
+
Copy,
|
|
9
|
+
Check,
|
|
10
|
+
type LucideIcon,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import type { Locale } from '@/i18n/config';
|
|
13
|
+
import { en } from '@/i18n/translations/en';
|
|
14
|
+
import { es } from '@/i18n/translations/es';
|
|
15
|
+
import { fr } from '@/i18n/translations/fr';
|
|
16
|
+
|
|
17
|
+
// Get translations for a specific locale
|
|
18
|
+
function getTranslations(locale: Locale) {
|
|
19
|
+
const translations = { en, es, fr };
|
|
20
|
+
return translations[locale] || translations.en;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Tab {
|
|
24
|
+
id: string;
|
|
25
|
+
icon: LucideIcon;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
locale?: Locale;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const codeExamples: Record<
|
|
33
|
+
string,
|
|
34
|
+
{ code: string; filename: string; lang: 'css' | 'astro' | 'typescript' | 'javascript' }
|
|
35
|
+
> = {
|
|
36
|
+
design: {
|
|
37
|
+
lang: 'css',
|
|
38
|
+
code: `/* src/styles/theme.css */
|
|
39
|
+
@theme {
|
|
40
|
+
--font-display: 'Outfit', sans-serif;
|
|
41
|
+
--font-body: 'Manrope', sans-serif;
|
|
42
|
+
|
|
43
|
+
--color-brand-500: oklch(0.623 0.214 25.667);
|
|
44
|
+
--color-brand-600: oklch(0.553 0.201 25.667);
|
|
45
|
+
|
|
46
|
+
--radius-sm: 0.25rem;
|
|
47
|
+
--radius-md: 0.5rem;
|
|
48
|
+
--radius-lg: 0.75rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Usage in components */
|
|
52
|
+
.btn-primary {
|
|
53
|
+
background: var(--color-brand-500);
|
|
54
|
+
font-family: var(--font-display);
|
|
55
|
+
}`,
|
|
56
|
+
filename: 'src/styles/theme.css',
|
|
57
|
+
},
|
|
58
|
+
seo: {
|
|
59
|
+
lang: 'astro',
|
|
60
|
+
code: `---
|
|
61
|
+
// src/layouts/Layout.astro
|
|
62
|
+
interface Props {
|
|
63
|
+
title: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
image?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { title, description, image } = Astro.props;
|
|
69
|
+
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
<head>
|
|
73
|
+
<title>{title} | Velocity</title>
|
|
74
|
+
<meta name="description" content={description} />
|
|
75
|
+
<link rel="canonical" href={canonicalURL} />
|
|
76
|
+
<meta property="og:image" content={image} />
|
|
77
|
+
</head>`,
|
|
78
|
+
filename: 'src/layouts/Layout.astro',
|
|
79
|
+
},
|
|
80
|
+
perf: {
|
|
81
|
+
lang: 'astro',
|
|
82
|
+
code: `---
|
|
83
|
+
// src/pages/index.astro
|
|
84
|
+
import Hero from '../components/Hero.astro';
|
|
85
|
+
import Calculator from '../components/Calculator.tsx';
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
<!-- Static HTML (0kb JS) -->
|
|
89
|
+
<Hero />
|
|
90
|
+
|
|
91
|
+
<!-- Hydrates only when visible -->
|
|
92
|
+
<Calculator client:visible />
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
// Optional: Performance observer
|
|
96
|
+
const observer = new PerformanceObserver((list) => {
|
|
97
|
+
list.getEntries().forEach(console.log);
|
|
98
|
+
});
|
|
99
|
+
observer.observe({ entryTypes: ['lcp'] });
|
|
100
|
+
</script>`,
|
|
101
|
+
filename: 'src/pages/index.astro',
|
|
102
|
+
},
|
|
103
|
+
components: {
|
|
104
|
+
lang: 'typescript',
|
|
105
|
+
code: `// src/components/ui/Button.tsx
|
|
106
|
+
import { cn } from '@/lib/cn';
|
|
107
|
+
|
|
108
|
+
interface ButtonProps {
|
|
109
|
+
variant?: 'primary' | 'secondary' | 'ghost';
|
|
110
|
+
size?: 'sm' | 'md' | 'lg';
|
|
111
|
+
children: React.ReactNode;
|
|
112
|
+
onClick?: () => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function Button({
|
|
116
|
+
variant = 'primary',
|
|
117
|
+
size = 'md',
|
|
118
|
+
children,
|
|
119
|
+
onClick
|
|
120
|
+
}: ButtonProps) {
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
onClick={onClick}
|
|
124
|
+
className={cn(
|
|
125
|
+
'font-medium rounded-md transition-colors',
|
|
126
|
+
variants[variant],
|
|
127
|
+
sizes[size]
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
</button>
|
|
132
|
+
);
|
|
133
|
+
}`,
|
|
134
|
+
filename: 'src/components/ui/Button.tsx',
|
|
135
|
+
},
|
|
136
|
+
i18n: {
|
|
137
|
+
lang: 'typescript',
|
|
138
|
+
code: `// src/i18n/config.ts
|
|
139
|
+
export const languages = {
|
|
140
|
+
en: 'English',
|
|
141
|
+
es: 'Español',
|
|
142
|
+
fr: 'Français',
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
export const defaultLang = 'en';
|
|
146
|
+
|
|
147
|
+
// src/i18n/translations.ts
|
|
148
|
+
export const translations = {
|
|
149
|
+
en: {
|
|
150
|
+
'nav.home': 'Home',
|
|
151
|
+
'nav.about': 'About',
|
|
152
|
+
'hero.title': 'Ship faster with Velocity',
|
|
153
|
+
},
|
|
154
|
+
es: {
|
|
155
|
+
'nav.home': 'Inicio',
|
|
156
|
+
'nav.about': 'Acerca de',
|
|
157
|
+
'hero.title': 'Envía más rápido con Velocity',
|
|
158
|
+
},
|
|
159
|
+
} as const;
|
|
160
|
+
|
|
161
|
+
// Usage: t('hero.title') → "Ship faster..."`,
|
|
162
|
+
filename: 'src/i18n/config.ts',
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Simple syntax highlighter
|
|
167
|
+
function highlightCode(code: string, lang: string): React.ReactNode[] {
|
|
168
|
+
const lines = code.split('\n');
|
|
169
|
+
|
|
170
|
+
return lines.map((line, lineIndex) => {
|
|
171
|
+
const tokens: React.ReactNode[] = [];
|
|
172
|
+
let remaining = line;
|
|
173
|
+
let keyIndex = 0;
|
|
174
|
+
|
|
175
|
+
const addToken = (text: string, className?: string) => {
|
|
176
|
+
if (text) {
|
|
177
|
+
tokens.push(
|
|
178
|
+
<span key={`${lineIndex}-${keyIndex++}`} className={className}>
|
|
179
|
+
{text}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Process the line character by character with regex patterns
|
|
186
|
+
while (remaining.length > 0) {
|
|
187
|
+
let matched = false;
|
|
188
|
+
|
|
189
|
+
// Comments (// and /* */)
|
|
190
|
+
const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/);
|
|
191
|
+
if (commentMatch) {
|
|
192
|
+
addToken(commentMatch[0], 'text-foreground-muted italic');
|
|
193
|
+
remaining = remaining.slice(commentMatch[0].length);
|
|
194
|
+
matched = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// HTML comments
|
|
199
|
+
const htmlCommentMatch = remaining.match(/^(<!--[\s\S]*?-->)/);
|
|
200
|
+
if (htmlCommentMatch) {
|
|
201
|
+
addToken(htmlCommentMatch[0], 'text-foreground-muted italic');
|
|
202
|
+
remaining = remaining.slice(htmlCommentMatch[0].length);
|
|
203
|
+
matched = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Strings (single, double, template)
|
|
208
|
+
const stringMatch = remaining.match(/^(['"`])(?:(?!\1)[^\\]|\\.)*\1/);
|
|
209
|
+
if (stringMatch) {
|
|
210
|
+
addToken(stringMatch[0], 'text-green-600 dark:text-green-400');
|
|
211
|
+
remaining = remaining.slice(stringMatch[0].length);
|
|
212
|
+
matched = true;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Astro frontmatter delimiters
|
|
217
|
+
if (remaining.startsWith('---')) {
|
|
218
|
+
addToken('---', 'text-purple-600 dark:text-purple-400 font-semibold');
|
|
219
|
+
remaining = remaining.slice(3);
|
|
220
|
+
matched = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// HTML/JSX tags
|
|
225
|
+
const tagMatch = remaining.match(/^(<\/?[\w-]+|>|\/>)/);
|
|
226
|
+
if (tagMatch) {
|
|
227
|
+
addToken(tagMatch[0], 'text-pink-600 dark:text-pink-400');
|
|
228
|
+
remaining = remaining.slice(tagMatch[0].length);
|
|
229
|
+
matched = true;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// CSS at-rules (@theme, @import, etc.)
|
|
234
|
+
const atRuleMatch = remaining.match(/^(@[\w-]+)/);
|
|
235
|
+
if (atRuleMatch) {
|
|
236
|
+
addToken(atRuleMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
|
237
|
+
remaining = remaining.slice(atRuleMatch[0].length);
|
|
238
|
+
matched = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Keywords
|
|
243
|
+
const keywordMatch = remaining.match(
|
|
244
|
+
/^(const|let|var|function|return|import|export|from|interface|type|class|extends|implements|new|async|await|if|else|for|while|switch|case|break|default|try|catch|finally|throw|typeof|instanceof|in|of|as|readonly|public|private|protected)\b/
|
|
245
|
+
);
|
|
246
|
+
if (keywordMatch) {
|
|
247
|
+
addToken(keywordMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
|
248
|
+
remaining = remaining.slice(keywordMatch[0].length);
|
|
249
|
+
matched = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Boolean/null
|
|
254
|
+
const boolMatch = remaining.match(/^(true|false|null|undefined)\b/);
|
|
255
|
+
if (boolMatch) {
|
|
256
|
+
addToken(boolMatch[0], 'text-orange-600 dark:text-orange-400');
|
|
257
|
+
remaining = remaining.slice(boolMatch[0].length);
|
|
258
|
+
matched = true;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Numbers
|
|
263
|
+
const numberMatch = remaining.match(/^(\d+\.?\d*)/);
|
|
264
|
+
if (numberMatch) {
|
|
265
|
+
addToken(numberMatch[0], 'text-orange-600 dark:text-orange-400');
|
|
266
|
+
remaining = remaining.slice(numberMatch[0].length);
|
|
267
|
+
matched = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// CSS properties (word followed by colon)
|
|
272
|
+
const cssPropMatch = remaining.match(/^([\w-]+)(:)/);
|
|
273
|
+
if (cssPropMatch && (lang === 'css' || line.includes('{'))) {
|
|
274
|
+
addToken(cssPropMatch[1], 'text-blue-600 dark:text-blue-400');
|
|
275
|
+
addToken(cssPropMatch[2], 'text-foreground-secondary');
|
|
276
|
+
remaining = remaining.slice(cssPropMatch[0].length);
|
|
277
|
+
matched = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// CSS functions (var, oklch, etc.)
|
|
282
|
+
const cssFuncMatch = remaining.match(
|
|
283
|
+
/^(var|oklch|rgb|rgba|hsl|hsla|calc|url|clamp|min|max)(\()/
|
|
284
|
+
);
|
|
285
|
+
if (cssFuncMatch) {
|
|
286
|
+
addToken(cssFuncMatch[1], 'text-yellow-600 dark:text-yellow-400');
|
|
287
|
+
addToken(cssFuncMatch[2], 'text-foreground-secondary');
|
|
288
|
+
remaining = remaining.slice(cssFuncMatch[0].length);
|
|
289
|
+
matched = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Function calls
|
|
294
|
+
const funcMatch = remaining.match(/^([\w]+)(\()/);
|
|
295
|
+
if (funcMatch) {
|
|
296
|
+
addToken(funcMatch[1], 'text-yellow-600 dark:text-yellow-400');
|
|
297
|
+
addToken(funcMatch[2], 'text-foreground-secondary');
|
|
298
|
+
remaining = remaining.slice(funcMatch[0].length);
|
|
299
|
+
matched = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Type annotations after colon
|
|
304
|
+
const typeMatch = remaining.match(/^(:\s*)([\w<>\[\]|&]+)/);
|
|
305
|
+
if (typeMatch) {
|
|
306
|
+
addToken(typeMatch[1], 'text-foreground-secondary');
|
|
307
|
+
addToken(typeMatch[2], 'text-cyan-600 dark:text-cyan-400');
|
|
308
|
+
remaining = remaining.slice(typeMatch[0].length);
|
|
309
|
+
matched = true;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Default: single character
|
|
314
|
+
if (!matched) {
|
|
315
|
+
addToken(remaining[0], 'text-foreground-secondary');
|
|
316
|
+
remaining = remaining.slice(1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return tokens.length > 0 ? tokens : [<span key={lineIndex}> </span>];
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function CodeBlock({ code, filename, lang, copyText, copiedText }: { code: string; filename: string; lang: string; copyText: string; copiedText: string }) {
|
|
325
|
+
const [copied, setCopied] = useState(false);
|
|
326
|
+
const highlightedLines = highlightCode(code.trim(), lang);
|
|
327
|
+
|
|
328
|
+
const handleCopy = async () => {
|
|
329
|
+
await navigator.clipboard.writeText(code);
|
|
330
|
+
setCopied(true);
|
|
331
|
+
setTimeout(() => setCopied(false), 2000);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div className="group border-border bg-background-secondary relative w-full overflow-hidden rounded-md border font-mono text-xs shadow-sm">
|
|
336
|
+
{/* Header */}
|
|
337
|
+
<div className="border-border bg-background flex items-center justify-between border-b px-4 py-2">
|
|
338
|
+
<div className="flex items-center gap-3">
|
|
339
|
+
<div className="flex gap-1.5">
|
|
340
|
+
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
|
341
|
+
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
|
342
|
+
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
|
343
|
+
</div>
|
|
344
|
+
<span className="text-foreground-muted font-sans text-[10px] font-medium">
|
|
345
|
+
{filename}
|
|
346
|
+
</span>
|
|
347
|
+
</div>
|
|
348
|
+
<button
|
|
349
|
+
onClick={handleCopy}
|
|
350
|
+
className="text-foreground-muted hover:bg-secondary hover:text-foreground flex items-center gap-1.5 rounded px-2 py-0.5 text-[10px] font-medium transition-colors"
|
|
351
|
+
>
|
|
352
|
+
{copied ? (
|
|
353
|
+
<>
|
|
354
|
+
<Check className="h-3 w-3 text-green-600" strokeWidth={2} />
|
|
355
|
+
<span className="text-green-600">{copiedText}</span>
|
|
356
|
+
</>
|
|
357
|
+
) : (
|
|
358
|
+
<>
|
|
359
|
+
<Copy className="h-3 w-3" strokeWidth={2} />
|
|
360
|
+
<span>{copyText}</span>
|
|
361
|
+
</>
|
|
362
|
+
)}
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Code Area */}
|
|
367
|
+
<div className="bg-background overflow-x-auto p-3">
|
|
368
|
+
<pre className="flex flex-col leading-5">
|
|
369
|
+
{highlightedLines.map((lineTokens, i) => (
|
|
370
|
+
<div key={i} className="table-row">
|
|
371
|
+
<span className="text-foreground-subtle table-cell w-6 pr-3 text-right text-[10px] select-none">
|
|
372
|
+
{i + 1}
|
|
373
|
+
</span>
|
|
374
|
+
<span className="table-cell whitespace-pre">{lineTokens}</span>
|
|
375
|
+
</div>
|
|
376
|
+
))}
|
|
377
|
+
</pre>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function FeatureTabs({ locale = 'en' }: Props) {
|
|
384
|
+
const [activeTab, setActiveTab] = useState('design');
|
|
385
|
+
const t = getTranslations(locale);
|
|
386
|
+
|
|
387
|
+
const tabs: Tab[] = [
|
|
388
|
+
{ id: 'design', icon: Palette },
|
|
389
|
+
{ id: 'seo', icon: Search },
|
|
390
|
+
{ id: 'perf', icon: Zap },
|
|
391
|
+
{ id: 'components', icon: LayoutGrid },
|
|
392
|
+
{ id: 'i18n', icon: Globe },
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
// Get translated content for the active tab
|
|
396
|
+
const activeTabData = t.features.tabs[activeTab as keyof typeof t.features.tabs];
|
|
397
|
+
const activeCodeExample = codeExamples[activeTab];
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<section id="features" className="bg-background py-24">
|
|
401
|
+
<div className="mx-auto max-w-6xl px-6">
|
|
402
|
+
{/* Header */}
|
|
403
|
+
<div className="mb-16">
|
|
404
|
+
<h2 className="font-display text-foreground text-3xl font-bold md:text-4xl">
|
|
405
|
+
{t.features.sectionTitle}
|
|
406
|
+
<br />
|
|
407
|
+
<span className="text-brand-500">{t.features.sectionTitleHighlight}</span>
|
|
408
|
+
</h2>
|
|
409
|
+
<p className="text-foreground-muted mt-4 max-w-2xl text-lg">
|
|
410
|
+
{t.features.sectionDescription}
|
|
411
|
+
</p>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
{/* Content Grid */}
|
|
415
|
+
<div className="grid grid-cols-1 gap-12 lg:grid-cols-12">
|
|
416
|
+
{/* Sidebar */}
|
|
417
|
+
<div className="flex flex-col gap-2 lg:col-span-4">
|
|
418
|
+
{tabs.map((tab) => {
|
|
419
|
+
const tabData = t.features.tabs[tab.id as keyof typeof t.features.tabs];
|
|
420
|
+
return (
|
|
421
|
+
<button
|
|
422
|
+
key={tab.id}
|
|
423
|
+
onClick={() => setActiveTab(tab.id)}
|
|
424
|
+
className={`group flex flex-col items-start rounded-md p-4 text-left transition-all ${
|
|
425
|
+
activeTab === tab.id
|
|
426
|
+
? 'bg-secondary ring-border shadow-sm ring-1'
|
|
427
|
+
: 'hover:bg-background-secondary hover:pl-5'
|
|
428
|
+
}`}
|
|
429
|
+
>
|
|
430
|
+
<span
|
|
431
|
+
className={`font-display flex items-center gap-2 text-base font-bold ${
|
|
432
|
+
activeTab === tab.id
|
|
433
|
+
? 'text-brand-600 dark:text-brand-400'
|
|
434
|
+
: 'text-foreground group-hover:text-brand-600 dark:group-hover:text-brand-400'
|
|
435
|
+
}`}
|
|
436
|
+
>
|
|
437
|
+
<tab.icon
|
|
438
|
+
className={`h-5 w-5 ${activeTab === tab.id ? 'text-brand-500' : 'text-foreground-subtle group-hover:text-brand-500'}`}
|
|
439
|
+
strokeWidth={2}
|
|
440
|
+
/>
|
|
441
|
+
{tabData.label}
|
|
442
|
+
</span>
|
|
443
|
+
<span className="text-foreground-muted mt-1 pl-7 text-sm">{tabData.desc}</span>
|
|
444
|
+
</button>
|
|
445
|
+
);
|
|
446
|
+
})}
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Content Preview */}
|
|
450
|
+
<div className="lg:col-span-8">
|
|
451
|
+
<div className="mb-6">
|
|
452
|
+
<h3 className="text-foreground text-xl font-bold">{activeTabData.title}</h3>
|
|
453
|
+
<p className="text-foreground-muted mt-2">{activeTabData.content}</p>
|
|
454
|
+
</div>
|
|
455
|
+
<CodeBlock
|
|
456
|
+
code={activeCodeExample.code}
|
|
457
|
+
filename={activeCodeExample.filename}
|
|
458
|
+
lang={activeCodeExample.lang}
|
|
459
|
+
copyText={t.common.copy}
|
|
460
|
+
copiedText={t.common.copied}
|
|
461
|
+
/>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
</section>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export default FeatureTabs;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Button from '@/components/ui/Button.astro';
|
|
3
|
+
import Icon from '@/components/ui/Icon.astro';
|
|
4
|
+
import TerminalDemo from '@/components/ui/TerminalDemo.tsx';
|
|
5
|
+
import { type Locale, defaultLocale } from '@/i18n/config';
|
|
6
|
+
import { useTranslations } from '@/i18n/index';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
locale?: Locale;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { locale = defaultLocale } = Astro.props;
|
|
13
|
+
const t = useTranslations(locale);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-32">
|
|
17
|
+
<!-- Background texture -->
|
|
18
|
+
<div
|
|
19
|
+
class="bg-grid-pattern pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_bottom,white,transparent)] opacity-40"
|
|
20
|
+
>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-12 px-6 lg:grid-cols-2 lg:gap-20">
|
|
24
|
+
<!-- Content -->
|
|
25
|
+
<div class="z-10 flex flex-col items-start text-left">
|
|
26
|
+
<!-- Badge -->
|
|
27
|
+
<div
|
|
28
|
+
class="border-border bg-background mb-6 inline-flex items-center rounded-full border px-3 py-1 shadow-sm"
|
|
29
|
+
>
|
|
30
|
+
<span class="bg-brand-500 mr-2 flex h-2 w-2 animate-pulse rounded-full"></span>
|
|
31
|
+
<span class="text-foreground-secondary text-xs font-medium">{t('hero.badge')}</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- Headline -->
|
|
35
|
+
<h1
|
|
36
|
+
class="font-display text-foreground mb-6 text-5xl leading-[1.1] font-bold tracking-tight text-balance md:text-6xl lg:text-7xl"
|
|
37
|
+
>
|
|
38
|
+
{t('hero.title')}
|
|
39
|
+
<span class="text-brand-500">{t('hero.titleHighlight')}</span>
|
|
40
|
+
</h1>
|
|
41
|
+
|
|
42
|
+
<!-- Description -->
|
|
43
|
+
<p class="text-foreground-muted mb-8 max-w-xl text-lg leading-relaxed">
|
|
44
|
+
{t('hero.description')}
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<!-- CTAs -->
|
|
48
|
+
<div class="flex w-full flex-col gap-4 sm:w-auto sm:flex-row">
|
|
49
|
+
<Button size="lg" href="#cta">
|
|
50
|
+
{t('hero.cta')}
|
|
51
|
+
<Icon name="arrow-right" size="sm" />
|
|
52
|
+
</Button>
|
|
53
|
+
<Button
|
|
54
|
+
size="lg"
|
|
55
|
+
variant="outline"
|
|
56
|
+
href="https://github.com/southwell-media/velocity"
|
|
57
|
+
target="_blank"
|
|
58
|
+
>
|
|
59
|
+
<Icon name="github" size="sm" />
|
|
60
|
+
{t('hero.github')}
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Social Proof -->
|
|
65
|
+
<div class="text-foreground-subtle mt-8 flex items-center gap-4 text-sm">
|
|
66
|
+
<div class="flex -space-x-2">
|
|
67
|
+
{
|
|
68
|
+
[1, 2, 3].map((i) => (
|
|
69
|
+
<div class="border-background bg-border dark:bg-background-tertiary text-foreground-muted flex h-8 w-8 items-center justify-center rounded-full border-2 text-[10px] font-bold">
|
|
70
|
+
{i === 3 ? '+' : ''}
|
|
71
|
+
</div>
|
|
72
|
+
))
|
|
73
|
+
}
|
|
74
|
+
</div>
|
|
75
|
+
<p>{t('hero.socialProof')}</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Terminal Demo -->
|
|
80
|
+
<div class="relative z-10 w-full">
|
|
81
|
+
<TerminalDemo client:load />
|
|
82
|
+
<!-- Decorative blob -->
|
|
83
|
+
<div
|
|
84
|
+
class="bg-brand-200/20 pointer-events-none absolute -top-20 -right-20 h-64 w-64 rounded-full blur-3xl"
|
|
85
|
+
>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { type Locale, defaultLocale, localePath } from '@/i18n/config';
|
|
3
|
+
import { useTranslations } from '@/i18n/index';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
locale?: Locale;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { locale = defaultLocale } = Astro.props;
|
|
10
|
+
const t = useTranslations(locale);
|
|
11
|
+
|
|
12
|
+
const links = [
|
|
13
|
+
{ labelKey: 'footer.links.documentation' as const, href: localePath('/blog', locale) },
|
|
14
|
+
{ labelKey: 'footer.links.github' as const, href: 'https://github.com/southwell-media/velocity' },
|
|
15
|
+
{ labelKey: 'footer.links.twitter' as const, href: 'https://twitter.com/southwellmedia' },
|
|
16
|
+
{ labelKey: 'footer.links.license' as const, href: 'https://github.com/southwell-media/velocity/blob/main/LICENSE' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const homeLink = locale === defaultLocale ? '/' : `/${locale}`;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<footer class="border-t border-border bg-background-secondary py-12">
|
|
23
|
+
<div class="mx-auto max-w-6xl px-6 flex flex-col md:flex-row justify-between items-center gap-6">
|
|
24
|
+
<!-- Logo & Attribution -->
|
|
25
|
+
<div class="flex flex-col gap-2">
|
|
26
|
+
<a href={homeLink} class="flex items-center gap-2">
|
|
27
|
+
<div class="h-5 w-5 bg-brand-500 rounded-sm"></div>
|
|
28
|
+
<span class="font-display text-lg font-bold text-foreground">Velocity</span>
|
|
29
|
+
</a>
|
|
30
|
+
<p class="text-sm text-foreground-subtle">
|
|
31
|
+
{t('footer.maintainedBy')} <a href="https://southwellmedia.com" class="underline hover:text-foreground transition-colors">Southwell Media</a>.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Links -->
|
|
36
|
+
<nav class="flex gap-8 text-sm text-foreground-muted font-medium">
|
|
37
|
+
{links.map((link) => (
|
|
38
|
+
<a
|
|
39
|
+
href={link.href}
|
|
40
|
+
class="hover:text-foreground transition-colors"
|
|
41
|
+
target={link.href.startsWith('http') ? '_blank' : undefined}
|
|
42
|
+
rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
43
|
+
>
|
|
44
|
+
{t(link.labelKey)}
|
|
45
|
+
</a>
|
|
46
|
+
))}
|
|
47
|
+
</nav>
|
|
48
|
+
</div>
|
|
49
|
+
</footer>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Icon from '@/components/ui/Icon.astro';
|
|
3
|
+
import { type Locale, defaultLocale } from '@/i18n/config';
|
|
4
|
+
import { useTranslations } from '@/i18n/index';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: Locale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = defaultLocale } = Astro.props;
|
|
11
|
+
const t = useTranslations(locale);
|
|
12
|
+
|
|
13
|
+
const stack = [
|
|
14
|
+
{
|
|
15
|
+
nameKey: 'techStack.astro.name' as const,
|
|
16
|
+
descKey: 'techStack.astro.desc' as const,
|
|
17
|
+
icon: 'zap' as const,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
nameKey: 'techStack.tailwind.name' as const,
|
|
21
|
+
descKey: 'techStack.tailwind.desc' as const,
|
|
22
|
+
icon: 'layout' as const,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
nameKey: 'techStack.typescript.name' as const,
|
|
26
|
+
descKey: 'techStack.typescript.desc' as const,
|
|
27
|
+
icon: 'shield' as const,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
nameKey: 'techStack.motion.name' as const,
|
|
31
|
+
descKey: 'techStack.motion.desc' as const,
|
|
32
|
+
icon: 'box' as const,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<section class="border-y border-border bg-background-secondary py-12">
|
|
38
|
+
<div class="mx-auto max-w-6xl px-6">
|
|
39
|
+
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
|
40
|
+
{stack.map((item) => (
|
|
41
|
+
<div class="flex flex-col items-center gap-2 text-center md:items-start md:text-left">
|
|
42
|
+
<div class="flex items-center gap-2 text-foreground font-display font-bold text-lg">
|
|
43
|
+
<span class="text-brand-500">
|
|
44
|
+
<Icon name={item.icon} size="md" />
|
|
45
|
+
</span>
|
|
46
|
+
{t(item.nameKey)}
|
|
47
|
+
</div>
|
|
48
|
+
<p class="text-sm text-foreground-muted font-medium">{t(item.descKey)}</p>
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|