Add Vercel product gallery site

This commit is contained in:
freestylefly
2026-05-04 10:50:19 +08:00
parent e7121b8fba
commit 5d0b113492
10 changed files with 11043 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
node_modules/
dist/
.vercel/
npm-debug.log*
.vercel
+8025
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Explore GPT-Image2 prompt cases with copyable prompts, visual filters, and direct GitHub links."
/>
<title>GPT-Image2 Prompt Gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1713
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "awesome-gpt-image-2-site",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"predev": "node scripts/generate-site-data.mjs",
"dev": "vite",
"prebuild": "node scripts/generate-site-data.mjs",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.7",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"lucide-react": "^0.561.0"
},
"devDependencies": {}
}
+211
View File
@@ -0,0 +1,211 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const docsDir = join(root, 'docs');
const outFile = join(root, 'data', 'cases.json');
const galleryFiles = [
{ file: 'gallery-part-1.md', part: 1 },
{ file: 'gallery-part-2.md', part: 2 }
];
const categoryLabels = {
'cat-ui': 'UI & Interfaces',
'cat-infographic': 'Charts & Infographics',
'cat-poster': 'Posters & Typography',
'cat-product': 'Products & E-commerce',
'cat-brand': 'Brand & Logos',
'cat-architecture': 'Architecture & Spaces',
'cat-photo': 'Photography & Realism',
'cat-illustration': 'Illustration & Art',
'cat-character': 'Characters & People',
'cat-scene': 'Scenes & Storytelling',
'cat-history': 'History & Classical Themes',
'cat-document': 'Documents & Publishing',
'cat-other': 'Other Use Cases'
};
const featuredIds = new Set([
1, 2, 6, 17, 166, 310, 330, 334, 338, 341, 344, 346, 350, 353, 354, 359, 360,
361, 362, 365, 370, 373, 375, 376, 377, 378
]);
function cleanText(value = '') {
return value
.replace(/\\_/g, '_')
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.trim();
}
function stripMarkdown(value = '') {
return cleanText(value)
.replace(/\*\*/g, '')
.replace(/`/g, '')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.trim();
}
function parseCategoryMap() {
const text = readFileSync(join(docsDir, 'gallery.md'), 'utf8');
const map = new Map();
const sections = text.split(/<a name="(cat-[^"]+)"><\/a>/g);
for (let i = 1; i < sections.length; i += 2) {
const categoryId = sections[i];
const body = sections[i + 1] || '';
const category = categoryLabels[categoryId] || 'Other Use Cases';
for (const match of body.matchAll(/#case-(\d+)\)/g)) {
map.set(Number(match[1]), category);
}
}
return map;
}
function extractPrompt(block) {
const normalized = block.replace(/\r/g, '');
const match = normalized.match(/\*\*提示词:\*\*[\s\S]*?```(?:text)?\n([\s\S]*?)```/);
return cleanText(match?.[1] || '');
}
function extractSource(block) {
const line = block.match(/\*\*来源:\*\*\s*([^\n]+)/)?.[1] || '';
const link = line.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (link) {
return {
label: stripMarkdown(link[1]),
url: link[2]
};
}
return {
label: stripMarkdown(line) || 'Community',
url: ''
};
}
function inferCategory(caseItem) {
if (caseItem.category) return caseItem.category;
const text = `${caseItem.title} ${caseItem.prompt}`.toLowerCase();
const rules = [
['UI & Interfaces', ['ui', 'app', 'interface', 'dashboard', 'screenshot', '网页', '界面', '截图']],
['Charts & Infographics', ['infographic', 'diagram', 'chart', 'atlas', '图谱', '信息图', '图解']],
['Posters & Typography', ['poster', 'cover', 'typography', '海报', '封面', '字体']],
['Products & E-commerce', ['product', 'packaging', 'e-commerce', '商品', '电商', '包装']],
['Brand & Logos', ['logo', 'brand', 'identity', '品牌', '标志']],
['Architecture & Spaces', ['architecture', 'interior', 'map', '建筑', '室内', '地图']],
['Photography & Realism', ['photo', 'portrait', 'camera', 'realistic', '写真', '摄影', '写实']],
['Illustration & Art', ['illustration', 'painting', 'watercolor', '插画', '艺术', '水墨']],
['Characters & People', ['character', 'pose', 'avatar', '角色', '人物', '头像']],
['Scenes & Storytelling', ['storyboard', 'scene', 'narrative', '场景', '叙事', '分镜']],
['History & Classical Themes', ['history', 'dynasty', 'classical', '历史', '古风', '唐朝', '宋']],
['Documents & Publishing', ['document', 'manual', 'prescription', '文档', '手册', '处方']]
];
return rules.find(([, keys]) => keys.some((key) => text.includes(key)))?.[0] || 'Other Use Cases';
}
function inferTags(caseItem) {
const text = `${caseItem.title} ${caseItem.prompt}`.toLowerCase();
const styleRules = [
['UI', ['ui', 'interface', 'dashboard', '界面']],
['Infographic', ['infographic', 'diagram', 'chart', '信息图', '图解']],
['Poster', ['poster', 'cover', 'typography', '海报', '封面']],
['Realistic', ['photo', 'realistic', 'camera', '写真', '写实']],
['Illustration', ['illustration', 'painting', 'watercolor', '插画', '绘画']],
['Product', ['product', 'packaging', '商品', '包装']],
['Brand', ['brand', 'logo', '品牌', '标志']],
['Character', ['character', 'avatar', 'pose', '角色', '人物']],
['Classical', ['classical', 'dynasty', 'history', '古风', '历史']],
['3D', ['3d', 'toy', 'render', '玩具']]
];
const sceneRules = [
['Tech', ['ai', 'rag', 'tech', 'data', '技术', '数据']],
['Commerce', ['product', 'brand', 'ad', 'campaign', '商品', '商业', '广告']],
['Education', ['guide', 'atlas', 'science', 'learning', '学习', '科普']],
['Social', ['social', 'x ', 'wechat', '朋友圈', '社媒']],
['Fashion', ['fashion', 'clothing', 'portrait', '服饰', '写真']],
['Food', ['food', 'drink', 'coffee', 'tea', '餐厅', '咖啡', '茶']],
['Travel', ['city', 'map', 'street', '城市', '地图', '街头']],
['Story', ['story', 'scene', 'world', '故事', '场景']],
['History', ['history', 'dynasty', 'ancient', '历史', '古希腊', '唐']]
];
const pick = (rules, fallback) => {
const tags = rules
.filter(([, keys]) => keys.some((key) => text.includes(key)))
.map(([label]) => label);
return tags.length ? tags.slice(0, 3) : [fallback];
};
return {
styles: pick(styleRules, caseItem.category.split(' & ')[0].replace('Posters', 'Poster')),
scenes: pick(sceneRules, 'Creative')
};
}
function parseCases() {
const categoryMap = parseCategoryMap();
const cases = [];
for (const { file, part } of galleryFiles) {
const text = readFileSync(join(docsDir, file), 'utf8');
const chunks = text.split(/<a name="case-(\d+)"><\/a>/g);
for (let i = 1; i < chunks.length; i += 2) {
const id = Number(chunks[i]);
const block = chunks[i + 1] || '';
const title = stripMarkdown(block.match(/###\s*例\s*\d+([^\n]+)/)?.[1] || `Case ${id}`);
const imageMatch = block.match(/!\[([^\]]*)\]\(([^)]+)\)/);
const prompt = extractPrompt(block);
const source = extractSource(block);
const category = inferCategory({
title,
prompt,
category: categoryMap.get(id)
});
const image = imageMatch?.[2]
? imageMatch[2].replace('../data/', '/')
: `/images/case${id}.jpg`;
const tags = inferTags({ title, prompt, category });
cases.push({
id,
title,
image,
imageAlt: stripMarkdown(imageMatch?.[1] || title),
sourceLabel: source.label,
sourceUrl: source.url,
prompt,
promptPreview: prompt.replace(/\n+/g, ' ').slice(0, 220),
category,
styles: tags.styles,
scenes: tags.scenes,
featured: featuredIds.has(id),
githubUrl: `https://github.com/freestylefly/awesome-gpt-image-2/blob/main/docs/gallery-part-${part}.md#case-${id}`
});
}
}
return cases.sort((a, b) => b.id - a.id);
}
const cases = parseCases();
const categories = [...new Set(cases.map((item) => item.category))].sort();
const styles = [...new Set(cases.flatMap((item) => item.styles))].sort();
const scenes = [...new Set(cases.flatMap((item) => item.scenes))].sort();
const payload = {
generatedAt: new Date().toISOString(),
repository: 'https://github.com/freestylefly/awesome-gpt-image-2',
totalCases: cases.length,
categories,
styles,
scenes,
cases
};
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, `${JSON.stringify(payload, null, 2)}\n`);
console.log(`Generated ${cases.length} cases at ${outFile}`);
+310
View File
@@ -0,0 +1,310 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
ArrowUpRight,
Check,
Copy,
Github,
Search,
Sparkles,
WandSparkles
} from 'lucide-react';
import './styles.css';
const fallbackRepoUrl = 'https://github.com/freestylefly/awesome-gpt-image-2';
function cx(...classes) {
return classes.filter(Boolean).join(' ');
}
function useCopy() {
const [copiedId, setCopiedId] = useState(null);
async function copyPrompt(caseItem) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(caseItem.prompt);
} else {
const textarea = document.createElement('textarea');
textarea.value = caseItem.prompt;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopiedId(caseItem.id);
window.setTimeout(() => setCopiedId(null), 1600);
}
return { copiedId, copyPrompt };
}
function Hero({ hotCases, repoUrl, totalCases, categoryCount }) {
return (
<section className="hero">
<div className="heroGlow heroGlowA" />
<div className="heroGlow heroGlowB" />
<div className="scanGrid" />
<div className="heroCopy">
<div className="eyebrow">
<Sparkles size={16} />
Live GPT-Image2 prompt gallery
</div>
<h1>Browse viral GPT-Image2 cases like a product catalog.</h1>
<p>
A visual front door for the awesome-gpt-image-2 repository: copy production-ready prompts,
filter by style or scene, and jump straight into the GitHub source.
</p>
<div className="heroActions">
<a className="primaryAction" href="#gallery">
Explore cases
<ArrowUpRight size={18} />
</a>
<a className="secondaryAction" href={repoUrl} target="_blank" rel="noreferrer">
<Github size={18} />
GitHub project
</a>
</div>
<div className="metrics">
<span><strong>{totalCases}</strong> cases</span>
<span><strong>{categoryCount}</strong> categories</span>
<span><strong>20+</strong> templates</span>
</div>
</div>
<div className="heroDeck" aria-label="Featured GPT-Image2 cases">
{hotCases.slice(0, 5).map((caseItem, index) => (
<a
className={`heroCard heroCard${index + 1}`}
href={caseItem.githubUrl}
target="_blank"
rel="noreferrer"
key={caseItem.id}
>
<img src={caseItem.image} alt={caseItem.imageAlt} />
<span>Case {caseItem.id}</span>
</a>
))}
</div>
</section>
);
}
function FilterPill({ active, children, onClick }) {
return (
<button className={cx('filterPill', active && 'active')} type="button" onClick={onClick}>
{children}
</button>
);
}
function PromptCard({ caseItem, copied, onCopy }) {
return (
<article className="caseCard">
<a className="caseImage" href={caseItem.githubUrl} target="_blank" rel="noreferrer">
<img src={caseItem.image} alt={caseItem.imageAlt} loading="lazy" />
<span className="caseBadge">Case {caseItem.id}</span>
</a>
<div className="caseBody">
<div className="caseMeta">
<span>{caseItem.category}</span>
{caseItem.sourceUrl ? (
<a href={caseItem.sourceUrl} target="_blank" rel="noreferrer">
{caseItem.sourceLabel}
</a>
) : (
<span>{caseItem.sourceLabel}</span>
)}
</div>
<h3>{caseItem.title}</h3>
<p>{caseItem.promptPreview}</p>
<div className="tagRow">
{[...new Set([...caseItem.styles, ...caseItem.scenes])].slice(0, 4).map((tag) => (
<span key={`${caseItem.id}-${tag}`}>{tag}</span>
))}
</div>
<div className="cardActions">
<button type="button" onClick={() => onCopy(caseItem)}>
{copied ? <Check size={17} /> : <Copy size={17} />}
{copied ? 'Copied' : 'Copy Prompt'}
</button>
<a href={caseItem.githubUrl} target="_blank" rel="noreferrer" aria-label="Open on GitHub">
<Github size={18} />
</a>
</div>
</div>
</article>
);
}
function App() {
const [siteData, setSiteData] = useState(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState('All');
const [style, setStyle] = useState('All');
const [scene, setScene] = useState('All');
const { copiedId, copyPrompt } = useCopy();
const repoUrl = siteData?.repository || fallbackRepoUrl;
useEffect(() => {
let cancelled = false;
fetch('/cases.json')
.then((response) => response.json())
.then((payload) => {
if (!cancelled) setSiteData(payload);
});
return () => {
cancelled = true;
};
}, []);
const hotCases = useMemo(() => {
if (!siteData) return [];
return [...siteData.cases]
.filter((item) => item.featured)
.sort((a, b) => b.id - a.id);
}, [siteData]);
const filteredCases = useMemo(() => {
if (!siteData) return [];
const q = query.trim().toLowerCase();
return siteData.cases.filter((item) => {
const matchQuery =
!q ||
`${item.id} ${item.title} ${item.category} ${item.prompt} ${item.sourceLabel}`
.toLowerCase()
.includes(q);
const matchCategory = category === 'All' || item.category === category;
const matchStyle = style === 'All' || item.styles.includes(style);
const matchScene = scene === 'All' || item.scenes.includes(scene);
return matchQuery && matchCategory && matchStyle && matchScene;
});
}, [siteData, query, category, style, scene]);
const visibleCases = filteredCases.slice(0, 72);
if (!siteData) {
return (
<main>
<div className="loadingScreen">
<WandSparkles size={28} />
<span>Loading GPT-Image2 cases...</span>
</div>
</main>
);
}
return (
<main>
<header className="topbar">
<a className="brand" href="#">
<WandSparkles size={21} />
GPT-Image2 Gallery
</a>
<nav>
<a href="#gallery">Cases</a>
<a href={repoUrl} target="_blank" rel="noreferrer">
GitHub
</a>
</nav>
</header>
<Hero
hotCases={hotCases}
repoUrl={repoUrl}
totalCases={siteData.totalCases}
categoryCount={siteData.categories.length}
/>
<section className="hotStrip">
{hotCases.slice(0, 8).map((caseItem) => (
<a href={caseItem.githubUrl} target="_blank" rel="noreferrer" key={caseItem.id}>
<img src={caseItem.image} alt={caseItem.imageAlt} />
<span>#{caseItem.id}</span>
</a>
))}
</section>
<section className="gallerySection" id="gallery">
<div className="sectionHead">
<div>
<span className="eyebrow">Copy, filter, remix</span>
<h2>Viral cases with prompts one click away.</h2>
</div>
<div className="searchBox">
<Search size={18} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search cases, sources, prompts..."
/>
</div>
</div>
<div className="filterPanel">
<div>
<strong>Category</strong>
<div className="filterRow">
<FilterPill active={category === 'All'} onClick={() => setCategory('All')}>All</FilterPill>
{siteData.categories.map((item) => (
<FilterPill key={item} active={category === item} onClick={() => setCategory(item)}>
{item}
</FilterPill>
))}
</div>
</div>
<div>
<strong>Style</strong>
<div className="filterRow">
<FilterPill active={style === 'All'} onClick={() => setStyle('All')}>All</FilterPill>
{siteData.styles.map((item) => (
<FilterPill key={item} active={style === item} onClick={() => setStyle(item)}>
{item}
</FilterPill>
))}
</div>
</div>
<div>
<strong>Scene</strong>
<div className="filterRow">
<FilterPill active={scene === 'All'} onClick={() => setScene('All')}>All</FilterPill>
{siteData.scenes.map((item) => (
<FilterPill key={item} active={scene === item} onClick={() => setScene(item)}>
{item}
</FilterPill>
))}
</div>
</div>
</div>
<div className="resultBar">
<span>{filteredCases.length} matching cases</span>
<a href={repoUrl} target="_blank" rel="noreferrer">
Open GitHub project
<ArrowUpRight size={16} />
</a>
</div>
<div className="caseGrid">
{visibleCases.map((caseItem) => (
<PromptCard
caseItem={caseItem}
copied={copiedId === caseItem.id}
onCopy={copyPrompt}
key={caseItem.id}
/>
))}
</div>
{filteredCases.length > visibleCases.length && (
<p className="limitNote">
Showing the first {visibleCases.length} results for speed. Use search or filters to narrow the gallery.
</p>
)}
</section>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);
+724
View File
@@ -0,0 +1,724 @@
:root {
color: #eef5ff;
background: #060914;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: geometricPrecision;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-width: 320px;
margin: 0;
background:
radial-gradient(circle at 12% 8%, rgba(47, 211, 255, 0.18), transparent 28rem),
radial-gradient(circle at 88% 0%, rgba(255, 73, 167, 0.16), transparent 30rem),
linear-gradient(180deg, #060914 0%, #0b1020 48%, #080b14 100%);
color: #eef5ff;
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.loadingScreen {
display: grid;
place-items: center;
gap: 14px;
min-height: 100vh;
color: #9eeeff;
font-weight: 800;
}
.loadingScreen svg {
animation: cardDrift 2.2s ease-in-out infinite;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
width: min(1180px, calc(100% - 32px));
height: 72px;
margin: 0 auto;
backdrop-filter: blur(18px);
}
.brand,
.topbar nav,
.eyebrow,
.heroActions a,
.metrics span,
.searchBox,
.filterPanel,
.resultBar,
.caseCard {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(9, 15, 32, 0.68);
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.28);
}
.brand,
.topbar nav {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 14px;
border-radius: 8px;
}
.brand {
font-weight: 800;
letter-spacing: 0;
}
.brand svg {
color: #50e7ff;
}
.topbar nav {
gap: 4px;
}
.topbar nav a {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 7px;
color: #b7c7df;
}
.topbar nav a:hover {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr);
gap: 44px;
align-items: center;
width: min(1180px, calc(100% - 32px));
min-height: calc(100vh - 92px);
margin: 0 auto;
padding: 56px 0 72px;
overflow: hidden;
}
.heroGlow,
.scanGrid {
position: absolute;
pointer-events: none;
}
.heroGlow {
width: 360px;
height: 360px;
border-radius: 999px;
filter: blur(20px);
opacity: 0.42;
animation: floatGlow 8s ease-in-out infinite;
}
.heroGlowA {
left: -130px;
top: 8%;
background: radial-gradient(circle, rgba(68, 222, 255, 0.42), transparent 64%);
}
.heroGlowB {
right: -120px;
bottom: 10%;
background: radial-gradient(circle, rgba(255, 74, 166, 0.34), transparent 62%);
animation-delay: -3s;
}
.scanGrid {
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.055) 1px, transparent 1px);
background-size: 58px 58px;
mask-image: linear-gradient(90deg, #000 0%, transparent 72%);
opacity: 0.4;
}
.heroCopy,
.heroDeck {
position: relative;
z-index: 1;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
color: #9eeeff;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
.hero h1 {
max-width: 780px;
margin: 22px 0 20px;
font-size: clamp(52px, 8vw, 92px);
line-height: 0.94;
letter-spacing: 0;
}
.hero p {
max-width: 650px;
margin: 0;
color: #b9c6d8;
font-size: 18px;
line-height: 1.75;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.heroActions a {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 48px;
padding: 0 18px;
border-radius: 8px;
font-weight: 800;
}
.heroActions .primaryAction {
background: linear-gradient(135deg, #42e6ff, #78ffb9 52%, #f9ff72);
color: #06101a;
}
.heroActions .secondaryAction {
color: #dfeaff;
}
.metrics {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.metrics span {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 12px;
border-radius: 8px;
color: #aebcd0;
}
.metrics strong {
margin-right: 6px;
color: #ffffff;
}
.heroDeck {
min-height: 610px;
}
.heroCard {
position: absolute;
display: block;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: #111a2c;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
transform: rotate(var(--tilt));
transition: transform 220ms ease, border-color 220ms ease;
animation: cardDrift 7s ease-in-out infinite;
}
.heroCard:hover {
border-color: rgba(103, 232, 249, 0.8);
transform: translateY(-8px) rotate(var(--tilt));
}
.heroCard img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCard span {
position: absolute;
left: 10px;
bottom: 10px;
padding: 6px 9px;
border-radius: 7px;
background: rgba(4, 9, 18, 0.72);
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.heroCard1 {
--tilt: -5deg;
inset: 30px auto auto 42px;
width: 285px;
height: 360px;
}
.heroCard2 {
--tilt: 4deg;
inset: 0 8px auto auto;
width: 240px;
height: 310px;
animation-delay: -1.2s;
}
.heroCard3 {
--tilt: 5deg;
inset: 318px auto auto 5px;
width: 220px;
height: 250px;
animation-delay: -2.3s;
}
.heroCard4 {
--tilt: -3deg;
inset: 300px 36px auto auto;
width: 300px;
height: 270px;
animation-delay: -3.4s;
}
.heroCard5 {
--tilt: 2deg;
inset: 196px auto auto 190px;
width: 210px;
height: 260px;
animation-delay: -4.2s;
}
.hotStrip {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 10px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto 84px;
}
.hotStrip a {
position: relative;
overflow: hidden;
aspect-ratio: 1 / 1;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.hotStrip img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 220ms ease;
}
.hotStrip a:hover img {
transform: scale(1.06);
}
.hotStrip span {
position: absolute;
left: 8px;
bottom: 8px;
padding: 5px 8px;
border-radius: 6px;
background: rgba(4, 9, 18, 0.74);
font-size: 12px;
font-weight: 800;
}
.gallerySection {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding-bottom: 80px;
}
.sectionHead {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 400px);
gap: 24px;
align-items: end;
margin-bottom: 22px;
}
.sectionHead h2 {
max-width: 760px;
margin: 16px 0 0;
font-size: clamp(34px, 5vw, 58px);
line-height: 1.04;
letter-spacing: 0;
}
.searchBox {
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 0 14px;
border-radius: 8px;
}
.searchBox svg {
color: #7de8ff;
flex: 0 0 auto;
}
.searchBox input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #ffffff;
}
.searchBox input::placeholder {
color: #73859f;
}
.filterPanel {
display: grid;
gap: 18px;
padding: 18px;
border-radius: 8px;
}
.filterPanel strong {
display: block;
margin-bottom: 10px;
color: #eaf3ff;
}
.filterRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filterPill {
min-height: 34px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 999px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.055);
color: #aebcd0;
}
.filterPill:hover,
.filterPill.active {
border-color: rgba(103, 232, 249, 0.82);
background: rgba(103, 232, 249, 0.16);
color: #ffffff;
}
.resultBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 46px;
margin: 18px 0;
padding: 0 14px;
border-radius: 8px;
color: #aebcd0;
}
.resultBar a {
display: inline-flex;
align-items: center;
gap: 6px;
color: #aef6ff;
font-weight: 800;
}
.caseGrid {
columns: 3 280px;
column-gap: 16px;
}
.caseCard {
display: inline-block;
width: 100%;
margin: 0 0 16px;
overflow: hidden;
border-radius: 8px;
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
}
.caseCard:hover {
border-color: rgba(103, 232, 249, 0.54);
background: rgba(14, 22, 42, 0.86);
transform: translateY(-4px);
}
.caseImage {
position: relative;
display: block;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.caseImage img {
display: block;
width: 100%;
height: auto;
}
.caseBadge {
position: absolute;
left: 10px;
top: 10px;
padding: 6px 9px;
border-radius: 7px;
background: rgba(4, 9, 18, 0.74);
color: #ffffff;
font-size: 12px;
font-weight: 900;
}
.caseBody {
padding: 16px;
}
.caseMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #7feaff;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.caseMeta a {
color: #c8ffb8;
}
.caseBody h3 {
margin: 10px 0 8px;
color: #ffffff;
font-size: 20px;
line-height: 1.25;
letter-spacing: 0;
}
.caseBody p {
display: -webkit-box;
min-height: 76px;
margin: 0;
overflow: hidden;
color: #aebcd0;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.tagRow {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 14px;
}
.tagRow span {
padding: 5px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: #c8d4e8;
font-size: 12px;
}
.cardActions {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
margin-top: 16px;
}
.cardActions button,
.cardActions a {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-weight: 800;
}
.cardActions button:hover,
.cardActions a:hover {
border-color: rgba(120, 255, 185, 0.74);
background: rgba(120, 255, 185, 0.14);
}
.limitNote {
margin: 24px 0 0;
color: #8d9bb0;
text-align: center;
}
@keyframes floatGlow {
0%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(24px, -18px, 0) scale(1.05);
}
}
@keyframes cardDrift {
0%,
100% {
translate: 0 0;
}
50% {
translate: 0 -12px;
}
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
min-height: auto;
}
.heroDeck {
min-height: 430px;
}
.heroCard1 {
left: 0;
width: 48%;
height: 300px;
}
.heroCard2 {
right: 0;
width: 44%;
height: 255px;
}
.heroCard3 {
top: 250px;
width: 38%;
height: 170px;
}
.heroCard4 {
top: 230px;
right: 0;
width: 52%;
height: 190px;
}
.heroCard5 {
display: none;
}
.hotStrip {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.sectionHead {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.topbar {
height: 64px;
}
.brand {
max-width: 205px;
}
.topbar nav a {
padding: 0 8px;
}
.hero {
width: min(100% - 24px, 1180px);
padding-top: 34px;
}
.hero h1 {
font-size: 48px;
}
.hero p {
font-size: 16px;
}
.heroActions a {
width: 100%;
}
.metrics span {
flex: 1 1 130px;
}
.hotStrip,
.gallerySection {
width: min(100% - 24px, 1180px);
}
.hotStrip {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 58px;
}
.resultBar {
align-items: flex-start;
flex-direction: column;
padding: 12px 14px;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"framework": "vite",
"buildCommand": "npm run build",
"outputDirectory": "dist",
"cleanUrls": true
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
publicDir: 'data',
build: {
outDir: 'dist',
sourcemap: false
}
});