Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
<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 Icon from '../components/Icon.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(typeof target === 'string' ? target : router.resolve(target).fullPath)
|
||||
}
|
||||
|
||||
const stats = ref({ generated_count: 0, recent: [] })
|
||||
const managed = ref([]) // managed model records (provider, type, ...)
|
||||
const showcase = ref({ hero: [], bento: [] })
|
||||
// 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 now requires a login and only returns the
|
||||
// caller's own entries. The avg-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: [] }
|
||||
// Randomize which hero card fronts the deck: shuffle the top-3 once when the
|
||||
// set first loads / changes; keep the order stable across refreshes so the
|
||||
// deck doesn't reshuffle every 30s while the visitor is looking at it.
|
||||
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))
|
||||
|
||||
// ---- KPI strip — three signals derived from real data ----
|
||||
const modelCount = computed(() => managed.value.length)
|
||||
|
||||
// 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 (avgElapsed.value / 1000).toFixed(1) + 's'
|
||||
})
|
||||
|
||||
// ---- “已接入” provider strip, derived from managed models ----
|
||||
const PROVIDER_TINT = {
|
||||
adobe: 'from-rose-400 to-orange-400',
|
||||
chatgpt: 'from-violet-500 to-fuchsia-500',
|
||||
google: 'from-sky-400 to-indigo-500',
|
||||
default: 'from-emerald-400 to-teal-500',
|
||||
}
|
||||
const providerGroups = computed(() => {
|
||||
const map = new Map()
|
||||
for (const m of managed.value) {
|
||||
const key = (m.provider || 'unknown').toLowerCase()
|
||||
const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 }
|
||||
if (m.type === 'video') g.video++
|
||||
else g.image++
|
||||
map.set(key, g)
|
||||
}
|
||||
return [...map.entries()].map(([key, g]) => ({
|
||||
...g,
|
||||
grad: PROVIDER_TINT[key] || PROVIDER_TINT.default,
|
||||
})).sort((a, b) => (b.image + b.video) - (a.image + a.video))
|
||||
})
|
||||
|
||||
// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work),
|
||||
// already sorted by weight on the server.
|
||||
const works = computed(() => showcase.value.work || [])
|
||||
|
||||
// Hero deck = a random ordering of the top-3 hero entries (see refresh).
|
||||
const bento = computed(() => showcase.value.bento || [])
|
||||
|
||||
// Resolve an image reference: external URLs pass through, relative paths
|
||||
// (like "user/abc.png") are served from /generated 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.
|
||||
function cardBg(card) {
|
||||
if (card.image) {
|
||||
return {
|
||||
backgroundImage: `url("${imgSrc(card.image)}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
return { background: card.gradient }
|
||||
}
|
||||
|
||||
function useExample(ex) {
|
||||
go('/user', { prompt: ex.prompt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-28">
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]">
|
||||
<!-- LEFT: copy -->
|
||||
<div>
|
||||
<h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]">
|
||||
<span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span>
|
||||
<span class="block">it,
|
||||
<span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span>
|
||||
it,
|
||||
</span>
|
||||
<span class="block">own it.</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
|
||||
把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<button @click="go('/user')"
|
||||
class="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>
|
||||
<a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 ↓</a>
|
||||
</div>
|
||||
|
||||
<!-- counter strip — three real signals: models we support, total
|
||||
outputs ever generated, average wall-clock to produce one. -->
|
||||
<div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)">
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: stacked card deck — driven by /admin/api/showcase (kind=hero),
|
||||
top 3 by weight. Position classes are picked by index so existing CSS
|
||||
transforms in <style> still apply. -->
|
||||
<div class="relative h-[480px] lg:h-[560px] hero-deck">
|
||||
<template v-for="(card, i) in heroDeck" :key="card.id">
|
||||
<!-- back -->
|
||||
<div v-if="i === 2"
|
||||
class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div v-if="i === 1"
|
||||
class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- front -->
|
||||
<div v-if="i === 0"
|
||||
class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div>
|
||||
<div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div>
|
||||
<p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WORKS STRIP ============ -->
|
||||
<section v-if="works.length" class="-mx-8 md:-mx-14">
|
||||
<div class="px-8 md:px-14 mb-6">
|
||||
<h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2>
|
||||
</div>
|
||||
<div class="marquee-wrap">
|
||||
<div class="marquee-track">
|
||||
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
|
||||
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
|
||||
@click="go('/logs')">
|
||||
<img :src="imgSrc(w.image)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ BENTO EXAMPLES ============ -->
|
||||
<section id="bento">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]">
|
||||
<button v-for="ex in bento" :key="ex.id" @click="useExample(ex)"
|
||||
class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all"
|
||||
:class="ex.span"
|
||||
:style="cardBg(ex)">
|
||||
<!-- grain -->
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<!-- veil -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
|
||||
<div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 md:p-6">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div>
|
||||
<div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div>
|
||||
<div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ MODELS GRID ============ -->
|
||||
<section>
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div v-for="p in providerGroups" :key="p.name"
|
||||
class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity"
|
||||
:class="p.grad"></div>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2">
|
||||
<span v-if="p.image">{{ p.image }} 图像</span>
|
||||
<span v-if="p.video">{{ p.video }} 视频</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10">
|
||||
管理员尚未接入任何模型
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ FOOTER CTA ============ -->
|
||||
<section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20">
|
||||
<div class="relative max-w-2xl">
|
||||
<h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]">
|
||||
停下来思考,<br />
|
||||
<span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始。
|
||||
</h3>
|
||||
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
|
||||
登录即可开始。打开画图工作台,写一句话,看 AI 替你完成。
|
||||
</p>
|
||||
<button @click="go('/user')"
|
||||
class="mt-10 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="pt-12 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-mono">{{ site.title }}</span>
|
||||
<span>·</span>
|
||||
<span>AI 生图与生视频平台</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
/* ----- Hero stacked card deck ----- */
|
||||
.hero-deck { perspective: 1200px; }
|
||||
.deck-card { transform-origin: center bottom; will-change: transform, opacity; }
|
||||
/* staggered fade-in-up on load, then a livelier continuous float with a gentle
|
||||
rotation wobble. The :hover rules below still take over to fan the deck out. */
|
||||
.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; }
|
||||
.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; }
|
||||
.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; }
|
||||
@keyframes deckFloat1 {
|
||||
0%,100% { transform: rotate(2deg) translateY(0); }
|
||||
35% { transform: rotate(0.5deg) translateY(-16px); }
|
||||
70% { transform: rotate(3deg) translateY(-7px); }
|
||||
}
|
||||
@keyframes deckFloat2 {
|
||||
0%,100% { transform: rotate(-3deg) translateY(0); }
|
||||
50% { transform: rotate(-1.5deg) translateY(-13px); }
|
||||
}
|
||||
@keyframes deckFloat3 {
|
||||
0%,100% { transform: rotate(5deg) translateY(0); }
|
||||
50% { transform: rotate(3.5deg) translateY(-10px); }
|
||||
}
|
||||
@keyframes deckIn1 {
|
||||
from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn2 {
|
||||
from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn3 {
|
||||
from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); }
|
||||
}
|
||||
.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; }
|
||||
|
||||
.marquee-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: max-content;
|
||||
padding: 0 2rem;
|
||||
animation: marquee 55s linear infinite;
|
||||
}
|
||||
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user