feat: v0.9.0~v0.9.1 — 5层极光首页 + 登录弹窗 + 播放器修复
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m0s

- 全新 Landing Page:Canvas 极光动画(5色光球 + additive blending + blur 融合)
- 暗角/胶片颗粒/渐变遮罩 4 层视觉架构
- 鼠标推动光球交互(lerp 缓动)+ 呼吸效果
- 登录弹窗(磨砂玻璃 backdrop-filter blur)替代独立登录页
- Air Spark 全屏毛玻璃弹窗 + 音乐彩蛋(SVG 音波 + BGM)
- 品牌名 AIRFLOW STUDIO / AI VISUAL NARRATIVE + Space Grotesk 字体
- 路由重构:/ → LandingPage, /login → 自动弹登录框
- 自适应视频播放器比例修复(读取 videoWidth/videoHeight)
- adaptive 英文显示改为中文「自适应」
- 首页音乐泄漏修复(组件卸载时 pause+reset)
- 登录弹窗添加「目前仅限受邀创作者体验」提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-16 05:40:41 +08:00
parent 6053c9b987
commit e5273540e9
17 changed files with 1104 additions and 30 deletions

View File

@ -4,6 +4,77 @@
--- ---
## 2026-03-16 — v0.9.1: 首页 + 播放器修复
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证)
### 变更内容
1. **首页音乐泄漏修复** — 登录跳转 `/app` 后 BGM 继续播放,添加组件卸载时 pause + reset
2. **自适应视频播放器比例修复**`adaptive` 比例视频在详情弹窗中固定 16:9 容器,改为读取视频固有 `videoWidth/videoHeight` 自适应缩放
3. **自适应比例中文化** — 视频详情弹窗、生成卡片标签、详情 tooltip 中 `adaptive` 改为显示「自适应」
4. **登录弹窗提示语** — 登录按钮下方添加「目前仅限受邀创作者体验」提示
### 变更文件
| 文件 | 改动 |
|------|------|
| `web/src/pages/LandingPage.tsx` | 添加 useEffect 清理:组件卸载时暂停音乐 |
| `web/src/components/VideoDetailModal.tsx` | adaptive 比例读取视频固有尺寸 + 显示中文「自适应」 |
| `web/src/components/GenerationCard.tsx` | 两处 aspectRatio 显示改为中文「自适应」 |
| `web/src/components/LoginModal.tsx` | 添加受邀创作者提示文字 |
| `web/src/components/LoginModal.module.css` | 新增 `.hint` 样式 |
---
## 2026-03-16 — v0.9.0: 品牌首页Landing Page
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证)
### 变更内容
1. **5 层极光首页** — 全新单屏 Landing PageCanvas 实现有机流动极光背景(暗角→胶片颗粒→极光光球→渐变遮罩→内容层)
2. **品牌标识** — 标题 "AIRFLOW STUDIO" + 副标题 "AI VISUAL NARRATIVE"Space Grotesk 字体300/400/500
3. **双入口按钮** — Air Drama品牌青色高亮/ Air Spark幽灵按钮中文副标题移至按钮下方独立小字
4. **登录弹窗** — 点击 Air Drama 原地弹出登录 Modal磨砂玻璃背景 + backdrop-filter: blur替代独立登录页跳转
5. **Air Spark 全屏提示** — 毛玻璃遮罩 + 戏剧性大字 "别急,土豆🥔正在 coding 中",点击任意位置关闭
6. **鼠标极光交互** — 鼠标靠近光球时轻推偏移28pxlerp 缓动跟踪,离开后缓慢回归
7. **极光呼吸感** — 5 个光球各自不同位置/大小/亮度/呼吸频率,避免均匀蓝块,保持明暗对比
8. **彩蛋文字** — 底部 "Every frame was once just air." opacity 0.06hover 2s 过渡到 0.25
9. **音乐彩蛋** — 右下角 SVG 音波图标,点击播放/暂停 BGM竖线从中心向两端扩展跳动JS 随机高度 + CSS transition
10. **路由重构**`/` → LandingPage, `/login` → LandingPage + 自动弹登录框, `/app` → VideoGenerationPage
11. **Logo 替换** — 全站 5 处 Logo 替换为品牌 Logofavicon、侧栏、管理后台、团队管理、登录弹窗
### 变更文件
| 文件 | 改动 |
|------|------|
| `web/src/components/AuroraCanvas.tsx` | 新建Canvas 极光 + 胶片颗粒 + 暗角 + 鼠标交互 |
| `web/src/components/LoginModal.tsx` | 新建:登录弹窗组件 |
| `web/src/components/LoginModal.module.css` | 新建:磨砂玻璃弹窗样式 |
| `web/src/pages/LandingPage.tsx` | 重写5 层架构 + 双按钮 + Air Spark 遮罩 + 音乐彩蛋 |
| `web/src/pages/LandingPage.module.css` | 重写:全新首页样式 |
| `web/src/App.tsx` | 路由重构:`/login``<LandingPage autoLogin />` |
| `web/index.html` | 添加 Space Grotesk 字体 + favicon |
| `web/src/components/Sidebar.tsx` | Logo 替换 + 导航 `/``/app` |
| `web/src/pages/AdminLayout.tsx` | Logo 替换 + 返回链接 `/app` |
| `web/src/pages/TeamAdminLayout.tsx` | Logo 替换 + 返回链接 `/app` |
| `web/src/pages/ProfilePage.tsx` | 返回链接 `/``/app` |
| `web/src/components/ProtectedRoute.tsx` | 未认证重定向 `/login` |
| `web/public/favicon.png` | 新增:品牌 favicon |
| `web/public/bgm.mp3` | 新增:首页背景音乐 |
| `web/src/assets/logo_32.png` | 新增:小尺寸 Logo |
| `web/src/assets/logo_128.png` | 新增:中尺寸 Logo |
| `web/src/assets/logo_512.png` | 新增:大尺寸 Logo |
### 触发原因
- 系统之前只有登录页,缺少品牌展示首页
- 需要对标 Runway/Pika 等 AI 视频平台的首页质感
- 两个子产品Air Drama / Air Spark需要统一入口
### 备注
- 旧 LoginPage.tsx 保留未删除,作为备用
- 极光效果使用 Canvas + `globalCompositeOperation: 'lighter'` + CSS `filter: blur(50px)`
- 移动端自动降级 Canvas 分辨率0.5x DPR
---
## 2026-03-16 — v0.8.5: 安全加固CRITICAL + HIGH 修复) ## 2026-03-16 — v0.8.5: 安全加固CRITICAL + HIGH 修复)
**状态**: ✅ 已完成 | **验收**: 待线上验证 **状态**: ✅ 已完成 | **验收**: 待线上验证

View File

@ -2,7 +2,10 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500&display=swap" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirDrama — AI 视频生成</title> <title>AirDrama — AI 视频生成</title>
</head> </head>

BIN
web/public/bgm.mp3 Normal file

Binary file not shown.

View File

@ -4,7 +4,7 @@ import { AmbientBackground } from './components/AmbientBackground';
import { Toast } from './components/Toast'; import { Toast } from './components/Toast';
import { VideoGenerationPage } from './components/VideoGenerationPage'; import { VideoGenerationPage } from './components/VideoGenerationPage';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage'; import { LandingPage } from './pages/LandingPage';
import { AdminLayout } from './pages/AdminLayout'; import { AdminLayout } from './pages/AdminLayout';
import { DashboardPage } from './pages/DashboardPage'; import { DashboardPage } from './pages/DashboardPage';
@ -34,9 +34,10 @@ export default function App() {
<AmbientBackground /> <AmbientBackground />
<Toast /> <Toast />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LandingPage autoLogin />} />
<Route <Route
path="/" path="/app"
element={ element={
<ProtectedRoute requireTeamMember> <ProtectedRoute requireTeamMember>
<VideoGenerationPage /> <VideoGenerationPage />

View File

@ -0,0 +1,250 @@
import { useRef, useEffect, useCallback } from 'react';
/**
* Aurora background 5 large diffuse orbs with additive blending.
* Orbs are deliberately offset from center to avoid uniform "blue blob" look.
* Mouse gently pushes nearby orbs (10-20px, very subtle).
*/
interface Orb {
cx: number; // base center X ratio (0-1)
cy: number; // base center Y ratio (0-1)
color: [number, number, number];
alpha: number; // peak alpha — varies per orb for brightness contrast
phase: number;
freqX: number;
freqY: number;
ampX: number;
ampY: number;
radius: number; // ratio of max(w,h)
// breathing
breathFreq: number;
breathAmp: number;
}
// Orbs deliberately NOT centered — some in corners, some offset,
// creating uneven light distribution with dark pockets.
const ORBS: Orb[] = [
// Cyan — upper-left area, large and bright
{ cx: 0.25, cy: 0.30, color: [126, 220, 200], alpha: 0.28,
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
radius: 0.50, breathFreq: 0.0004, breathAmp: 0.06 },
// Purple — lower-right, medium
{ cx: 0.72, cy: 0.65, color: [108, 99, 255], alpha: 0.22,
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
radius: 0.45, breathFreq: 0.0003, breathAmp: 0.08 },
// Blue — center-right, smaller and dimmer (fills gap)
{ cx: 0.58, cy: 0.38, color: [59, 130, 246], alpha: 0.18,
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
radius: 0.38, breathFreq: 0.0005, breathAmp: 0.07 },
// Light purple — bottom-left corner, dim accent
{ cx: 0.20, cy: 0.70, color: [167, 139, 250], alpha: 0.15,
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
radius: 0.40, breathFreq: 0.00035, breathAmp: 0.10 },
// Bright cyan — upper-right, small bright accent
{ cx: 0.78, cy: 0.25, color: [34, 211, 238], alpha: 0.20,
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
radius: 0.35, breathFreq: 0.00045, breathAmp: 0.09 },
];
// Mouse influence radius (px) and max push distance (px)
const MOUSE_RADIUS = 400;
const MOUSE_PUSH = 28;
export function AuroraCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const grainRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef({ x: -9999, y: -9999, active: false });
const handleMouseMove = useCallback((e: MouseEvent) => {
mouseRef.current.x = e.clientX;
mouseRef.current.y = e.clientY;
mouseRef.current.active = true;
}, []);
const handleMouseLeave = useCallback(() => {
mouseRef.current.active = false;
}, []);
// ── Aurora animation ──
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = window.innerWidth;
let h = window.innerHeight;
function resize() {
w = window.innerWidth;
h = window.innerHeight;
const dpr = w < 768 ? 0.5 : Math.min(window.devicePixelRatio, 1.5);
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = w + 'px';
canvas!.style.height = h + 'px';
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
let animId: number;
const t0 = performance.now();
// Smoothed mouse position for gentle push
let smoothMx = -9999;
let smoothMy = -9999;
function draw(now: number) {
const t = now - t0;
ctx!.clearRect(0, 0, w, h);
ctx!.globalCompositeOperation = 'lighter';
// Smooth mouse tracking (lerp toward actual position)
const mouse = mouseRef.current;
if (mouse.active) {
smoothMx += (mouse.x - smoothMx) * 0.035;
smoothMy += (mouse.y - smoothMy) * 0.035;
} else {
// Slowly drift smoothed mouse away (return to no-influence)
smoothMx += (-9999 - smoothMx) * 0.01;
smoothMy += (-9999 - smoothMy) * 0.01;
}
for (const orb of ORBS) {
// Base position from slow sinusoidal movement
let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX);
let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY);
// Mouse push — gently offset orb away from cursor
const dx = x - smoothMx;
const dy = y - smoothMy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_RADIUS && dist > 1) {
const strength = (1 - dist / MOUSE_RADIUS) * MOUSE_PUSH;
x += (dx / dist) * strength;
y += (dy / dist) * strength;
}
// Breathing: radius and alpha pulse slowly
const breathT = Math.sin(t * orb.breathFreq + orb.phase * 1.3);
const r = Math.max(w, h) * orb.radius * (1 + breathT * orb.breathAmp);
const a = orb.alpha * (1 + breathT * 0.15);
const [cr, cg, cb] = orb.color;
const grad = ctx!.createRadialGradient(x, y, 0, x, y, r);
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(a).toFixed(3)})`);
grad.addColorStop(0.25, `rgba(${cr},${cg},${cb},${(a * 0.5).toFixed(3)})`);
grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},${(a * 0.15).toFixed(3)})`);
grad.addColorStop(0.8, `rgba(${cr},${cg},${cb},${(a * 0.03).toFixed(3)})`);
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx!.fillStyle = grad;
ctx!.beginPath();
ctx!.arc(x, y, r, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalCompositeOperation = 'source-over';
animId = requestAnimationFrame(draw);
}
animId = requestAnimationFrame(draw);
window.addEventListener('resize', resize);
window.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [handleMouseMove, handleMouseLeave]);
// ── Film grain — 4 FPS low-noise ──
useEffect(() => {
const canvas = grainRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = 256;
canvas.height = 256;
let animId: number;
let lastFrame = 0;
function drawGrain(now: number) {
if (now - lastFrame > 250) {
lastFrame = now;
const img = ctx!.createImageData(256, 256);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
ctx!.putImageData(img, 0, 0);
}
animId = requestAnimationFrame(drawGrain);
}
animId = requestAnimationFrame(drawGrain);
return () => cancelAnimationFrame(animId);
}, []);
return (
<>
{/* Layer 1: Vignette — radial darkening, heavy at edges */}
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.8) 100%)',
pointerEvents: 'none',
}}
/>
{/* Layer 2: Film grain */}
<canvas
ref={grainRef}
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
width: '100%',
height: '100%',
opacity: 0.035,
pointerEvents: 'none',
mixBlendMode: 'overlay',
}}
/>
{/* Layer 3: Aurora — blur merges orbs into organic glow */}
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
zIndex: 3,
filter: 'blur(50px)',
pointerEvents: 'none',
}}
/>
{/* Layer 4: Top/bottom gradient mask */}
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 4,
pointerEvents: 'none',
background:
'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.5) 100%)',
}}
/>
</>
);
}

View File

@ -227,7 +227,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span> </span>
<span className={styles.label}>{task.duration}s</span> <span className={styles.label}>{task.duration}s</span>
<span className={styles.label}>{task.aspectRatio}</span> <span className={styles.label}>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
<span <span
ref={detailLinkRef} ref={detailLinkRef}
className={styles.detailLink} className={styles.detailLink}
@ -248,7 +248,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{detailHover && ( {detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}> <div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span> <span></span><span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
</div> </div>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<span></span><span>{task.duration}s</span> <span></span><span>{task.duration}s</span>

View File

@ -0,0 +1,168 @@
.overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
animation: overlayIn 0.3s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: relative;
width: 100%;
max-width: 400px;
margin: 0 20px;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 36px 32px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: panelIn 0.3s ease-out;
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.closeBtn {
position: absolute;
top: 14px;
right: 14px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.closeBtn:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.06);
}
.header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
}
.headerLogo {
width: 28px;
height: 28px;
}
.headerTitle {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 400;
color: #f1f0ff;
letter-spacing: 0.05em;
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
color: #8b8ea8;
font-weight: 500;
}
.input {
height: 44px;
padding: 0 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #f1f0ff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input::placeholder {
color: #4c4f6b;
}
.input:focus {
border-color: rgba(126, 220, 200, 0.5);
}
.error {
color: #ff4d4f;
font-size: 13px;
text-align: center;
padding: 8px;
background: rgba(255, 77, 79, 0.08);
border-radius: 8px;
}
.submitBtn {
height: 44px;
width: 55%;
align-self: center;
margin-top: 18px;
background: rgba(120, 220, 200, 0.08);
border: 1px solid rgba(120, 220, 200, 0.3);
color: #7edcc8;
border-radius: 10px;
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.submitBtn:hover {
background: rgba(120, 220, 200, 0.18);
box-shadow: 0 0 24px rgba(120, 220, 200, 0.12);
}
.submitBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
text-align: center;
margin: 0;
}

View File

@ -0,0 +1,89 @@
import { useState, useCallback } from 'react';
import { useAuthStore } from '../store/auth';
import logoImg from '../assets/logo_32.png';
import styles from './LoginModal.module.css';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function LoginModal({ isOpen, onClose, onSuccess }: Props) {
const login = useAuthStore((s) => s.login);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) { setError('请输入用户名或邮箱'); return; }
if (password.length < 6) { setError('密码至少6位'); return; }
setLoading(true);
try {
await login(username, password);
onSuccess();
} catch (err: any) {
const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试';
setError(msg);
} finally {
setLoading(false);
}
}, [username, password, login, onSuccess]);
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div className={styles.header}>
<img src={logoImg} alt="" className={styles.headerLogo} />
<span className={styles.headerTitle}>Air Drama</span>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label className={styles.label}> / </label>
<input
type="text"
className={styles.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名或邮箱"
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.submitBtn} disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
<p className={styles.hint}></p>
</form>
</div>
</div>
);
}

View File

@ -34,11 +34,11 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
} }
if (requireAdmin && user?.role !== 'super_admin') { if (requireAdmin && user?.role !== 'super_admin') {
return <Navigate to="/" replace />; return <Navigate to="/app" replace />;
} }
if (requireTeamAdmin && user?.role !== 'team_admin') { if (requireTeamAdmin && user?.role !== 'team_admin') {
return <Navigate to="/" replace />; return <Navigate to="/app" replace />;
} }
// requireTeamMember: must have a team (team_admin or member) // requireTeamMember: must have a team (team_admin or member)

View File

@ -1,5 +1,6 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import logoImg from '../assets/logo_32.png';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
export function Sidebar() { export function Sidebar() {
@ -18,12 +19,8 @@ export function Sidebar() {
return ( return (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
{/* Logo */} {/* Logo */}
<div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/')}> <div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/app')}>
<svg width="32" height="32" viewBox="0 0 28 28" fill="none"> <img src={logoImg} alt="AirDrama" width="32" height="32" />
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#6c63ff" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#8b83ff" />
<path d="M10 10L18 6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.6" />
</svg>
</div> </div>
{/* Nav items */} {/* Nav items */}
@ -32,8 +29,8 @@ export function Sidebar() {
{role !== 'super_admin' && ( {role !== 'super_admin' && (
<> <>
<div <div
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`} className={`${styles.navItem} ${isActive('/app') ? styles.active : ''}`}
onClick={() => navigate('/')} onClick={() => navigate('/app')}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" /> <path d="M13 10V3L4 14h7v7l9-11h-7z" />

View File

@ -31,15 +31,19 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
const [showMoreMenu, setShowMoreMenu] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null); const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null); const moreMenuRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>(); const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Parse aspect ratio from task // Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio
const arNum = useMemo(() => { const arNum = useMemo(() => {
const ar = task?.aspectRatio || '16:9'; const ar = task?.aspectRatio || '16:9';
if (ar === 'adaptive') {
return intrinsicRatio || 16 / 9;
}
const parts = ar.split(':').map(Number); const parts = ar.split(':').map(Number);
return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9; return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9;
}, [task?.aspectRatio]); }, [task?.aspectRatio, intrinsicRatio]);
// Compute container size to fit aspect ratio within videoArea // Compute container size to fit aspect ratio within videoArea
useEffect(() => { useEffect(() => {
@ -94,6 +98,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
setShowMoreMenu(false); setShowMoreMenu(false);
setIntrinsicRatio(null);
}, [task?.id]); }, [task?.id]);
// Track fullscreen changes // Track fullscreen changes
@ -128,7 +133,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
const v = videoRef.current; const v = videoRef.current;
if (v) setDuration(v.duration); if (v) {
setDuration(v.duration);
if (v.videoWidth && v.videoHeight) {
setIntrinsicRatio(v.videoWidth / v.videoHeight);
}
}
}; };
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
@ -467,7 +477,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>{task.duration}s</span> <span>{task.duration}s</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>{task.aspectRatio}</span> <span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
</div> </div>
<div className={styles.cardActions}> <div className={styles.cardActions}>

View File

@ -1,6 +1,7 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useState } from 'react'; import { useState } from 'react';
import logoImg from '../assets/logo_32.png';
import styles from './AdminLayout.module.css'; import styles from './AdminLayout.module.css';
const navItems = [ const navItems = [
@ -28,9 +29,7 @@ export function AdminLayout() {
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}> <aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<div className={styles.logo}> <div className={styles.logo}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)"> <img src={logoImg} alt="AirDrama" width="24" height="24" />
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
</svg>
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>} {!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
</div> </div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}> <button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
@ -45,7 +44,7 @@ export function AdminLayout() {
</div> </div>
<nav className={styles.nav}> <nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}> <button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg> </svg>

View File

@ -0,0 +1,319 @@
.page {
position: relative;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
z-index: 2;
}
/* ── Layer 5: Content ── */
.content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: fadeUp 1.2s ease-out both;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
width: 80px;
height: 80px;
margin-bottom: 28px;
filter: drop-shadow(0 0 40px rgba(126, 220, 200, 0.25));
animation: fadeUp 1.2s ease-out 0.1s both;
}
.title {
font-family: 'Space Grotesk', sans-serif;
font-size: 48px;
font-weight: 300;
color: #f1f0ff;
letter-spacing: 0.1em;
margin-bottom: 12px;
line-height: 1.1;
animation: fadeUp 1.2s ease-out 0.2s both;
}
.tagline {
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 300;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.15em;
margin-bottom: 48px;
animation: fadeUp 1.2s ease-out 0.3s both;
}
/* ── Buttons ── */
.actions {
display: flex;
gap: 24px;
align-items: flex-start;
animation: fadeUp 1.2s ease-out 0.4s both;
}
.btnGroup {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.btnPrimary,
.btnGhost {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 32px;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.btnPrimary {
background: rgba(120, 220, 200, 0.12);
border: 1px solid rgba(120, 220, 200, 0.3);
}
.btnPrimary .btnName {
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
color: #7edcc8;
}
.btnPrimary:hover {
background: rgba(120, 220, 200, 0.22);
box-shadow: 0 0 30px rgba(120, 220, 200, 0.15);
}
.btnGhost {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btnGhost .btnName {
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.btnGhost:hover {
background: rgba(255, 255, 255, 0.1);
}
.btnGhost:hover .btnName {
color: rgba(255, 255, 255, 0.9);
}
/* Sub-text below buttons */
.btnSub {
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 300;
color: rgba(120, 220, 200, 0.5);
}
.btnSubGhost {
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 300;
color: rgba(255, 255, 255, 0.35);
}
/* ── Easter egg ── */
.easter {
position: absolute;
bottom: 36px;
left: 0;
right: 0;
z-index: 10;
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
font-weight: 300;
font-style: italic;
color: rgba(255, 255, 255, 0.06);
letter-spacing: 0.05em;
cursor: default;
transition: color 2s ease;
white-space: nowrap;
text-align: center;
animation: fadeUp 1.2s ease-out 0.8s both;
}
.easter:hover {
color: rgba(255, 255, 255, 0.25);
}
/* ── Air Spark full-screen overlay ── */
.sparkOverlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
animation: sparkBgIn 0.5s ease-out both;
}
@keyframes sparkBgIn {
from {
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
background: rgba(0, 0, 0, 0);
}
to {
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
background: rgba(0, 0, 0, 0.5);
}
}
.sparkContent {
display: flex;
flex-direction: column;
align-items: center;
animation: sparkTextIn 0.4s ease-out 0.1s both;
}
@keyframes sparkTextIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.sparkTitle {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(40px, 5vw, 64px);
font-weight: 300;
color: #ffffff;
line-height: 1.2;
text-align: center;
}
.sparkEmoji {
font-size: 0.6em;
vertical-align: baseline;
}
.sparkSub {
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 300;
color: rgba(255, 255, 255, 0.5);
margin-top: 20px;
}
/* ── Music button — bottom right ── */
.musicBtn {
position: absolute;
bottom: 25px;
right: 32px;
z-index: 10;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
outline: none;
appearance: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
transition: color 0.3s;
animation: fadeUp 1.2s ease-out 0.8s both;
}
.musicBtn:hover {
color: rgba(255, 255, 255, 0.5);
}
/* ── Responsive ── */
@media (max-width: 768px) {
.logo {
width: 60px;
height: 60px;
}
.title {
font-size: 32px;
}
.tagline {
font-size: 12px;
margin-bottom: 36px;
}
.actions {
flex-direction: column;
gap: 16px;
}
.btnPrimary,
.btnGhost {
min-width: 200px;
}
.easter {
font-size: 11px;
bottom: 24px;
}
.musicBtn {
bottom: 24px;
right: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.content,
.logo,
.title,
.tagline,
.actions,
.easter,
.sparkOverlay,
.sparkContent,
.musicBtn {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}

View File

@ -0,0 +1,166 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { AuroraCanvas } from '../components/AuroraCanvas';
import { LoginModal } from '../components/LoginModal';
import logoImg from '../assets/logo_512.png';
import styles from './LandingPage.module.css';
interface Props {
autoLogin?: boolean;
}
export function LandingPage({ autoLogin }: Props) {
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [showLogin, setShowLogin] = useState(false);
const [showSpark, setShowSpark] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [barHeights, setBarHeights] = useState([0.4, 0.4, 0.4, 0.4]);
// Auto-open login modal when rendered via /login route
useEffect(() => {
if (autoLogin && !isAuthenticated) {
setShowLogin(true);
}
}, [autoLogin, isAuthenticated]);
const handleAirDrama = () => {
if (isAuthenticated) {
navigate('/app');
} else {
setShowLogin(true);
}
};
const handleAirSpark = () => {
setShowSpark(true);
};
const handleLoginSuccess = () => {
setShowLogin(false);
navigate('/app', { replace: true });
};
// Close spark overlay on click
const closeSpark = useCallback(() => setShowSpark(false), []);
// Animate bar heights when playing
useEffect(() => {
if (!playing) {
setBarHeights([0.4, 0.4, 0.4, 0.4]);
return;
}
const id = setInterval(() => {
setBarHeights([
0.3 + Math.random() * 0.7,
0.3 + Math.random() * 0.7,
0.3 + Math.random() * 0.7,
0.3 + Math.random() * 0.7,
]);
}, 150);
return () => clearInterval(id);
}, [playing]);
// Stop music on unmount (e.g. navigating to /app after login)
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
};
}, []);
// Music toggle
const toggleMusic = useCallback(() => {
if (!audioRef.current) {
audioRef.current = new Audio('/bgm.mp3');
audioRef.current.loop = true;
}
if (playing) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setPlaying((p) => !p);
}, [playing]);
return (
<div className={styles.page}>
{/* Layer 1-4: Aurora background */}
<AuroraCanvas />
{/* Layer 5: Content */}
<div className={styles.content}>
<img src={logoImg} alt="AirDrama" className={styles.logo} />
<h1 className={styles.title}>AIRFLOW STUDIO</h1>
<p className={styles.tagline}>AI VISUAL NARRATIVE</p>
<div className={styles.actions}>
<div className={styles.btnGroup}>
<button className={styles.btnPrimary} onClick={handleAirDrama}>
<span className={styles.btnName}>Air Drama</span>
</button>
<span className={styles.btnSub}></span>
</div>
<div className={styles.btnGroup}>
<button className={styles.btnGhost} onClick={handleAirSpark}>
<span className={styles.btnName}>Air Spark</span>
</button>
<span className={styles.btnSubGhost}></span>
</div>
</div>
</div>
{/* Easter egg */}
<p className={styles.easter}>Every frame was once just air.</p>
{/* Air Spark full-screen overlay */}
{showSpark && (
<div className={styles.sparkOverlay} onClick={closeSpark}>
<div className={styles.sparkContent}>
<p className={styles.sparkTitle}><span className={styles.sparkEmoji}>🥔</span> coding </p>
<p className={styles.sparkSub}>线</p>
</div>
</div>
)}
{/* Music easter egg — bottom right */}
<button
className={styles.musicBtn}
onClick={toggleMusic}
aria-label={playing ? '暂停音乐' : '播放音乐'}
>
<svg width="20" height="18" viewBox="0 0 20 18" fill="none">
{[0, 1, 2, 3].map((i) => {
const maxH = 16;
const h = maxH * barHeights[i];
const y = 9 - h / 2; // center at y=9
return (
<rect
key={i}
x={1 + i * 5}
y={y}
width="3"
height={h}
rx="1.5"
fill="currentColor"
style={{ transition: 'y 0.15s ease, height 0.15s ease' }}
/>
);
})}
</svg>
</button>
{/* Login modal */}
<LoginModal
isOpen={showLogin}
onClose={() => setShowLogin(false)}
onSuccess={handleLoginSuccess}
/>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import logoImg from '../assets/logo_128.png';
import styles from './AuthPage.module.css'; import styles from './AuthPage.module.css';
export function LoginPage() { export function LoginPage() {
@ -21,7 +22,7 @@ export function LoginPage() {
setLoading(true); setLoading(true);
try { try {
await login(username, password); await login(username, password);
navigate('/', { replace: true }); navigate('/app', { replace: true });
} catch (err: any) { } catch (err: any) {
const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试'; const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试';
setError(msg); setError(msg);
@ -33,6 +34,7 @@ export function LoginPage() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.card}> <div className={styles.card}>
<img src={logoImg} alt="AirDrama" width="64" height="64" style={{ marginBottom: '12px' }} />
<h1 className={styles.title}>AirDrama</h1> <h1 className={styles.title}>AirDrama</h1>
<p className={styles.subtitle}>AI </p> <p className={styles.subtitle}>AI </p>

View File

@ -108,7 +108,7 @@ export function ProfilePage() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<header className={styles.header}> <header className={styles.header}>
<button className={styles.backBtn} onClick={() => navigate('/')}> <button className={styles.backBtn} onClick={() => navigate('/app')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"> <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg> </svg>

View File

@ -1,6 +1,7 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useState } from 'react'; import { useState } from 'react';
import logoImg from '../assets/logo_32.png';
import styles from './AdminLayout.module.css'; import styles from './AdminLayout.module.css';
const navItems = [ const navItems = [
@ -24,9 +25,7 @@ export function TeamAdminLayout() {
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}> <aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<div className={styles.logo}> <div className={styles.logo}>
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)"> <img src={logoImg} alt="AirDrama" width="24" height="24" />
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
</svg>
{!collapsed && <span className={styles.logoText}></span>} {!collapsed && <span className={styles.logoText}></span>}
</div> </div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}> <button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
@ -41,7 +40,7 @@ export function TeamAdminLayout() {
</div> </div>
<nav className={styles.nav}> <nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}> <button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg> </svg>