Files
image2api/frontend/src/views/HomeView.vue
T

704 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, generatedUrl } from '../api'
import { site } from '../site'
import { isAuthed, openLogin } from '../auth'
import Mascot from '../components/Mascot.vue'
const router = useRouter()
// Navigate to a signed-in page, or pop the login modal (remembering the
// destination) when the visitor isn't logged in yet.
function go(path, query) {
const target = query ? { path, query } : { path }
if (isAuthed()) { router.push(target); return }
openLogin(router.resolve(target).fullPath)
}
const stats = ref({ generated_count: 0, recent: [] })
const managed = ref([]) // managed model records (type, resolutions, ...)
const showcase = ref({ hero: [], bento: [], work: [] })
// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card
// fronts the deck on each page load. It's reshuffled only when the hero set first
// loads or its members change — not on every 30s poll, so the deck stays put.
const heroDeck = ref([])
function shuffleArr(arr) {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
let timer = null
async function refresh() {
try {
// No /logs here — that endpoint requires a login and only returns the
// caller's own entries. The latency KPI comes from /stats (aggregate,
// prompt-free) so the public home page exposes nothing per-user.
const [s, m, sc] = await Promise.all([
api('/stats'),
api('/managed-models'),
api('/showcase'),
])
stats.value = s.data || { generated_count: 0, recent: [] }
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
showcase.value = sc.data?.data || { hero: [], bento: [], work: [] }
const top3 = (showcase.value.hero || []).slice(0, 3)
const sameSet = heroDeck.value.length === top3.length
&& top3.every((c) => heroDeck.value.some((d) => d.id === c.id))
if (!sameSet) heroDeck.value = shuffleArr(top3)
} catch {}
}
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
onUnmounted(() => clearInterval(timer))
// ---- showcase groups: all three kinds are used ----
const bento = computed(() => showcase.value.bento || [])
const works = computed(() => showcase.value.work || [])
// hero + bento imagery, flattened into one strip for the scrolling gallery
// marquee under the hero. Duplicated inline in the template so the loop reads
// seamlessly when it wraps around.
const gallery = computed(() => {
const seen = new Set()
const out = []
for (const c of [...(showcase.value.hero || []), ...bento.value]) {
if (c && !seen.has(c.id)) { seen.add(c.id); out.push(c) }
}
return out
})
// ---- KPI strip — real signals only ----
const imageModels = computed(() => managed.value.filter((m) => m.type !== 'video'))
const videoModels = computed(() => managed.value.filter((m) => m.type === 'video'))
const modelCount = computed(() => managed.value.length)
// Resolution range across image models, e.g. "1K4K" (or a single tier).
const RES_ORDER = ['1K', '2K', '4K']
const resRange = computed(() => {
const seen = new Set()
for (const m of imageModels.value) for (const r of m.resolutions || []) seen.add(r)
const tiers = RES_ORDER.filter((r) => seen.has(r))
if (!tiers.length) return '—'
return tiers.length === 1 ? tiers[0] : `${tiers[0]}${tiers[tiers.length - 1]}`
})
// Show the 24h average (matches the admin overview); fall back to the all-time
// average on a quiet day so the KPI isn't blank.
const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null)
const avgLabel = computed(() => {
if (avgElapsed.value == null) return '—'
if (avgElapsed.value < 1000) return avgElapsed.value + 'ms'
return Math.round(avgElapsed.value / 1000) + 's'
})
// ---- capability cards, derived from the enabled models ----
function refs(m) { return m.image_to_image === true || Number(m.max_reference_images || 0) > 0 }
const durationRange = computed(() => {
const secs = []
for (const m of videoModels.value) {
for (const d of m.durations || []) {
const n = parseInt(String(d), 10)
if (Number.isFinite(n)) secs.push(n)
}
}
if (!secs.length) return ''
const lo = Math.min(...secs), hi = Math.max(...secs)
return lo === hi ? `${lo}s` : `${lo}${hi}s`
})
const videoRes = computed(() => {
const seen = new Set()
for (const m of videoModels.value) for (const r of m.resolutions || []) seen.add(r)
return [...seen].sort().join(' / ')
})
// Each card deep-links into the workbench with ?model=<id> (the only intent
// PlaygroundView reads besides ?prompt), picking the first model that actually
// offers that capability — a card with no model behind it is dropped.
const caps = computed(() => {
const i2iModels = imageModels.value.filter(refs)
const i2vModels = videoModels.value.filter(refs)
return [
{
key: 't2i', title: '文字生图', model: imageModels.value[0]?.id,
desc: '一句话出图,画幅与分辨率按所选模型的可用范围给出。',
tint: 'rgb(52 211 153 / 0.14)', color: 'rgb(52 211 153)',
metas: [resRange.value, `${imageModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2i', title: '参考图生图', model: i2iModels[0]?.id,
desc: '上传参考图换风格、改构图、做变体,支持多张参考。',
tint: 'rgb(217 70 239 / 0.14)', color: 'rgb(232 121 249)',
metas: ['多图参考', `${i2iModels.length} 个模型`],
},
{
key: 't2v', title: '文字生视频', model: videoModels.value[0]?.id,
desc: '一段描述生成短片,时长与分辨率按模型能力可选。',
tint: 'rgb(14 165 233 / 0.14)', color: 'rgb(56 189 248)',
metas: [durationRange.value, videoRes.value, `${videoModels.value.length} 个模型`].filter(Boolean),
},
{
key: 'i2v', title: '图生视频', model: i2vModels[0]?.id,
desc: '给参考图或首帧,让静态画面动起来。',
tint: 'rgb(245 158 11 / 0.14)', color: 'rgb(251 191 36)',
metas: ['参考图', `${i2vModels.length} 个模型`],
},
].filter((c) => c.model)
})
// ---- model cards: the admin-set alias wins, so no upstream naming leaks ----
const MODEL_TINT = ['#a78bfa', '#f0abfc', '#fb7185', '#fbbf24', '#38bdf8', '#34d399', '#94a3b8']
const modelCards = computed(() => managed.value.map((m, i) => {
const bits = []
if (m.resolutions?.length) bits.push(m.resolutions.join(' / '))
if (m.type === 'video' && m.durations?.length) {
const lo = parseInt(m.durations[0], 10)
const hi = parseInt(m.durations[m.durations.length - 1], 10)
bits.push(lo === hi ? `${lo}s` : `${lo}${hi}s`)
}
if (m.type !== 'video') bits.push(refs(m) ? '支持参考图' : '文字生图')
return {
id: m.id,
name: m.alias || m.name || m.id,
spec: bits.join(' · '),
video: m.type === 'video',
glow: MODEL_TINT[i % MODEL_TINT.length],
}
}))
// Resolve an image reference: external URLs pass through, relative paths
// (like "user/abc.png") are served from /images by the backend.
function imgSrc(image) {
if (!image) return ''
return /^https?:\/\//i.test(image) ? image : generatedUrl(image)
}
// Background style for a showcase card. Prefers a real image (the new shape);
// falls back to the legacy CSS gradient so seed entries still render.
// background-image (not <img>) keeps Edge's 视觉搜索 overlay off the artwork.
function cardBg(card) {
if (card.image) {
return {
backgroundImage: `url("${imgSrc(card.image)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
return { background: card.gradient }
}
const prompt = ref('')
function submitPrompt() {
const p = prompt.value.trim()
go('/user', p ? { prompt: p } : null)
}
function useExample(ex) {
go('/user', ex.prompt ? { prompt: ex.prompt } : null)
}
</script>
<template>
<div class="px-4 md:px-8">
<!-- ============ HERO (centered, mascot-forward) ============ -->
<section class="hero-band relative overflow-hidden pt-16 md:pt-24 pb-10">
<div class="anime-stars"></div>
<div class="relative flex flex-col items-center text-center">
<!-- mascot centerpiece with soft aura -->
<div class="relative mb-6">
<span class="mascot-aura"></span>
<Mascot :size="184" class="relative" />
</div>
<span class="sticker">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgb(52_211_153)]"></span>
{{ modelCount }} 个模型在线 · 图像与视频一站生成
</span>
<h1 class="mt-5 font-extrabold tracking-tight leading-[1.05] text-[clamp(2.6rem,6vw,4.6rem)] text-[color:var(--fg)]">
把想象 <span class="anime-grad-text">说给 AI </span>
</h1>
<p class="mt-5 text-[15px] md:text-base text-[color:var(--fg-2)] max-w-xl leading-relaxed">
写一句话剩下的交给我们自动选模型自动排队图像与视频都在同一个工作台里出片
</p>
<!-- prompt composer the shortest path into the workbench -->
<form class="composer mt-8 !max-w-2xl w-full" @submit.prevent="submitPrompt">
<input v-model="prompt" class="composer-input" type="text"
placeholder="描述你想要的画面,比如:雨夜的东京小巷,霓虹倒映在湿滑路面…" />
<button type="submit" class="composer-go"> 生成</button>
</form>
<!-- quick starters come from the curated bento set -->
<div v-if="bento.length" class="mt-4 flex flex-wrap justify-center gap-2 max-w-2xl">
<button v-for="ex in bento.slice(0, 5)" :key="ex.id" type="button" class="chip"
@click="useExample(ex)">{{ ex.title }}</button>
</div>
</div>
</section>
<!-- ============ GALLERY MARQUEE (hero + bento imagery) ============ -->
<section v-if="gallery.length" class="mt-2 -mx-4 md:-mx-8 overflow-hidden marquee-mask">
<div class="marquee flex gap-4 px-4 md:px-8 w-max">
<button v-for="(card, i) in [...gallery, ...gallery]" :key="card.id + '-' + i" type="button"
class="gcard" :style="cardBg(card)" :aria-label="card.title" @click="useExample(card)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ card.subtitle }}</i>
<b>{{ card.title }}</b>
</span>
</button>
</div>
</section>
<!-- ============ KPI STRIP ============ -->
<section class="mt-12 grid grid-cols-2 md:grid-cols-4 gap-3">
<div v-for="k in [
{ v: modelCount, l: '在线模型', e: '🧩' },
{ v: caps.length, l: '创作方式', e: '🎨' },
{ v: resRange, l: '输出分辨率', e: '🖼️' },
{ v: avgLabel, l: '平均出片 · 近 24h', e: '⚡' },
]" :key="k.l"
class="anime-card px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-lg">{{ k.e }}</span>
<span class="text-[26px] font-bold tabular-nums text-[color:var(--fg)]">{{ k.v }}</span>
</div>
<div class="mt-1.5 text-[10px] uppercase tracking-[0.18em] text-[color:var(--fg-3)]">{{ k.l }}</div>
</div>
</section>
<!-- ============ CAPABILITIES ============ -->
<section class="mt-24">
<div class="anime-eyebrow text-violet-500 dark:text-violet-300/80">能力</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">
{{ caps.length }} 种方式开始创作
</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
分辨率与时长直接读取模型配置,页面上写的就是你实际能选的。
</p>
<div class="mt-8 grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
<button v-for="c in caps" :key="c.key" type="button" class="cap-card"
@click="go('/user', { model: c.model })">
<span class="cap-arrow">↗</span>
<span class="cap-icon" :style="{ background: c.tint, color: c.color }">
<svg v-if="c.key === 't2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M4 15l4.5-4.5 3 3L15 10l5 5"/><circle cx="9" cy="9" r="1.3"/></svg>
<svg v-else-if="c.key === 'i2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 7h11v11H3z"/><path d="M10 4h11v11"/></svg>
<svg v-else-if="c.key === 't2v'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16v14H4z"/><path d="M9 9l6 3-6 3z"/></svg>
</span>
<span class="block text-[15.5px] font-bold text-[color:var(--fg)]">{{ c.title }}</span>
<span class="mt-2 block text-[12.5px] leading-relaxed text-[color:var(--fg-3)]">{{ c.desc }}</span>
<span class="mt-3.5 flex flex-wrap gap-1.5">
<i v-for="mt in c.metas" :key="mt" class="cap-meta">{{ mt }}</i>
</span>
</button>
</div>
</section>
<!-- ============ INSPIRATION (kind=bento) ============ -->
<section v-if="bento.length" id="bento" class="mt-24">
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300/80">灵感</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">点一下,套用同款提示词</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
任选一张,自动进入画图工作台并预填提示词。
</p>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 grid-flow-row-dense gap-3 auto-rows-[170px]">
<button v-for="ex in bento" :key="ex.id" type="button" class="tile" :class="ex.span"
:style="cardBg(ex)" @click="useExample(ex)">
<span class="tile-veil"></span>
<span class="tile-cap">
<i>{{ ex.subtitle }}</i>
<b>{{ ex.title }}</b>
<em>{{ ex.prompt }}</em>
</span>
</button>
</div>
</section>
<!-- ============ WORKS (kind=work) ============ -->
<section v-if="works.length" class="mt-24">
<div class="anime-eyebrow text-sky-500 dark:text-sky-300/80">作品</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">大家都在生成什么</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
精选公开作品,点开即用同一条提示词继续创作。
</p>
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<button v-for="w in works" :key="w.id" type="button" class="work" @click="useExample(w)">
<span class="work-img" :style="cardBg(w)"></span>
<span class="work-overlay">
<b>{{ w.title }}</b>
<em>{{ w.prompt }}</em>
<i class="work-reuse">套用提示词</i>
</span>
</button>
</div>
</section>
<!-- ============ MODELS ============ -->
<section v-if="modelCards.length" class="mt-24">
<div class="anime-eyebrow text-emerald-500 dark:text-emerald-300/80">模型</div>
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">在线模型</h2>
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
每个模型的分辨率、时长与参考图能力都写在卡片上,选之前就知道能做什么。
</p>
<div class="mt-8 flex gap-3 overflow-x-auto pb-2 model-row">
<div v-for="m in modelCards" :key="m.id" class="model-card">
<span class="model-glow" :style="{ background: m.glow }"></span>
<div class="relative text-[15px] font-bold text-[color:var(--fg)]">{{ m.name }}</div>
<div class="relative mt-1.5 text-[11px] text-[color:var(--fg-3)]">{{ m.spec }}</div>
<div class="relative mt-3.5">
<span :class="['model-tag', m.video ? 'is-video' : 'is-image']">{{ m.video ? '视频' : '图像' }}</span>
</div>
</div>
</div>
</section>
<!-- ============ CTA ============ -->
<section class="mt-24 cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-10 md:p-16">
<div class="relative max-w-xl">
<h3 class="text-[clamp(1.7rem,3.4vw,2.8rem)] font-extrabold tracking-tight leading-[1.2] text-[color:var(--fg)]">
与其继续想,<br />不如现在写一句。
</h3>
<p class="mt-4 text-[15px] text-[color:var(--fg-2)] max-w-md leading-relaxed">
注册即可开始,图像与视频都能试;不满意就换个模型,额度按次计费。
</p>
<button type="button" @click="go('/user')"
class="mt-7 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
免费开始
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">→</span>
</button>
</div>
</section>
<!-- thin footer line -->
<footer class="mt-16 pt-10 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
<div class="flex items-center gap-3">
<span class="font-semibold text-[color:var(--fg-2)]">{{ site.title }}</span>
<span>·</span>
<span>AI 生图与生视频平台</span>
</div>
<div class="flex items-center gap-4">
<router-link to="/docs" class="hover:text-[color:var(--fg)] transition-colors">文档</router-link>
<router-link to="/about" class="hover:text-[color:var(--fg)] transition-colors">关于</router-link>
</div>
</footer>
</div>
</template>
<style scoped>
/* ---------- composer ---------- */
.composer {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 36rem;
padding: 0.5rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 50px -22px rgb(139 92 246 / 0.3);
transition: box-shadow 0.25s ease;
}
.composer:focus-within {
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.55), 0 20px 60px -20px rgb(139 92 246 / 0.45);
}
.composer-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--fg);
font-size: 15px;
padding: 0.7rem 0.85rem;
}
.composer-input::placeholder { color: var(--fg-faint); }
.composer-go {
flex: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 14px;
font-weight: 600;
padding: 0.7rem 1.25rem;
border-radius: 0.9rem;
background: linear-gradient(135deg, #e879f9, #8b5cf6);
transition: transform 0.15s ease, filter 0.15s ease;
}
.composer-go:hover { transform: translateY(-1px); filter: brightness(1.1); }
.chip {
font-size: 12.5px;
color: var(--fg-2);
cursor: pointer;
border: none;
padding: 0.42rem 0.8rem;
border-radius: 999px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: color 0.15s ease, box-shadow 0.15s ease;
}
.chip:hover { color: var(--fg); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.5); }
/* ---------- hero cards (kind=hero) ---------- */
.fcard {
position: absolute;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
overflow: hidden;
box-shadow: 0 30px 70px -24px rgb(0 0 0 / 0.55), inset 0 0 0 1px rgb(255 255 255 / 0.14);
transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
animation: fcardIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
}
.fcard-1 { width: 58%; height: 72%; top: 4%; right: 0; z-index: 3; transform: rotate(1.5deg); }
.fcard-2 { width: 46%; height: 44%; bottom: 2%; left: 4%; z-index: 2; transform: rotate(-2.5deg); animation-delay: 0.1s; }
.fcard-3 { width: 34%; height: 36%; top: 0; left: 0; z-index: 1; transform: rotate(4deg); opacity: 0.92; animation-delay: 0.2s; }
/* the back card is partly covered — keep only its title legible */
.fcard-3 .fcard-cap em { display: none; }
.fcard:hover { transform: translateY(-8px) rotate(0deg) scale(1.02); z-index: 4; }
@keyframes fcardIn {
from { opacity: 0; transform: translateY(28px) scale(0.95); }
}
.fcard-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82), transparent 58%);
}
.fcard-cap {
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0.9rem;
text-align: left;
color: rgb(255 255 255 / 0.82);
}
.fcard-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.18em; color: rgb(255 255 255 / 0.6); }
.fcard-cap b { display: block; font-size: 14px; margin-top: 0.2rem; color: #fff; }
.fcard-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.5;
margin-top: 0.25rem;
}
/* ---------- capability cards ---------- */
.cap-card {
position: relative;
overflow: hidden;
text-align: left;
cursor: pointer;
border: none;
padding: 1.35rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.cap-card:hover {
transform: translateY(-4px);
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45), 0 20px 40px -24px rgb(139 92 246 / 0.45);
}
.cap-arrow { position: absolute; top: 1.1rem; right: 1.1rem; color: var(--fg-3); transition: color 0.15s ease; }
.cap-card:hover .cap-arrow { color: var(--fg); }
.cap-icon {
width: 2.6rem;
height: 2.6rem;
border-radius: 0.85rem;
display: grid;
place-items: center;
margin-bottom: 0.9rem;
}
.cap-icon svg { width: 1.25rem; height: 1.25rem; }
.cap-meta {
font-style: normal;
font-size: 10px;
color: var(--fg-2);
padding: 0.18rem 0.5rem;
border-radius: 0.4rem;
background: var(--surface-2);
}
/* ---------- bento tiles (kind=bento) ---------- */
.tile {
position: relative;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.25rem;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.35s ease;
}
.tile:hover { transform: translateY(-3px); }
.tile-veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.82) 0%, rgb(0 0 0 / 0.28) 45%, transparent 72%);
}
.tile-cap { position: absolute; left: 1rem; right: 1rem; bottom: 0.9rem; text-align: left; color: #fff; }
.tile-cap i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: 0.2em; color: rgb(255 255 255 / 0.66); }
.tile-cap b { display: block; font-size: 15px; margin-top: 0.2rem; }
.tile-cap em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.55;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.72);
}
/* ---------- works (kind=work) ---------- */
.work {
position: relative;
display: block;
width: 100%;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.1rem;
box-shadow: inset 0 0 0 1px var(--hairline);
}
.work-img { display: block; width: 100%; aspect-ratio: 4 / 5; transition: transform 0.5s ease; }
.work:hover .work-img { transform: scale(1.04); }
.work-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
text-align: left;
padding: 1rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.86) 0%, rgb(0 0 0 / 0.3) 50%, transparent 78%);
opacity: 0;
transition: opacity 0.25s ease;
}
.work:hover .work-overlay, .work:focus-visible .work-overlay { opacity: 1; }
/* touch devices have no hover — keep the caption visible there */
@media (hover: none) { .work-overlay { opacity: 1; } }
.work-overlay b { color: #fff; font-size: 14px; }
.work-overlay em {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-style: normal;
font-size: 11.5px;
line-height: 1.6;
margin-top: 0.3rem;
color: rgb(255 255 255 / 0.8);
}
.work-reuse {
margin-top: 0.7rem;
font-style: normal;
font-size: 12px;
font-weight: 600;
color: rgb(11 13 19);
background: #fff;
border-radius: 999px;
padding: 0.4rem 0.8rem;
}
/* ---------- model row ---------- */
.model-row::-webkit-scrollbar { height: 6px; }
.model-row::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 3px; }
.model-card {
position: relative;
flex: none;
width: 14.5rem;
overflow: hidden;
padding: 1.25rem;
border-radius: 1.25rem;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: box-shadow 0.2s ease;
}
.model-card:hover { box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.42); }
.model-glow {
position: absolute;
top: -2.5rem;
right: -2.5rem;
width: 8rem;
height: 8rem;
border-radius: 50%;
filter: blur(40px);
opacity: 0.3;
}
.model-tag { font-size: 10.5px; padding: 0.18rem 0.55rem; border-radius: 0.4rem; }
.model-tag.is-image { background: rgb(52 211 153 / 0.14); color: rgb(52 211 153); }
.model-tag.is-video { background: rgb(125 211 252 / 0.14); color: rgb(56 189 248); }
/* ---------- hero band + mascot aura ---------- */
.hero-band {
border-radius: 2rem;
}
.mascot-aura {
position: absolute;
left: 50%;
top: 50%;
width: 260px;
height: 260px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgb(217 70 239 / 0.35), rgb(56 189 248 / 0.12) 55%, transparent 72%);
filter: blur(14px);
animation: auraPulse 5s ease-in-out infinite;
pointer-events: none;
}
@keyframes auraPulse {
0%, 100% { opacity: 0.8; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
}
/* ---------- gallery marquee ---------- */
.marquee-mask {
-webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}
.marquee {
animation: marqueeScroll 42s linear infinite;
}
.marquee:hover { animation-play-state: paused; }
@keyframes marqueeScroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.gcard {
position: relative;
flex: none;
width: 15rem;
height: 10rem;
overflow: hidden;
border: none;
padding: 0;
cursor: pointer;
border-radius: 1.4rem;
background-size: cover;
background-position: center;
box-shadow: inset 0 0 0 1px var(--hairline);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.gcard:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 22px 44px -26px rgb(168 85 247 / 0.6);
}
@media (prefers-reduced-motion: reduce) {
.marquee { animation: none; }
.mascot-aura { animation: none; }
}
</style>