feat: add home page and refactor workspace entry UI

This commit is contained in:
saturn
2026-03-23 17:45:17 +08:00
parent a6ad11b9c4
commit 4e469074e0
48 changed files with 2970 additions and 453 deletions

View File

@@ -9,6 +9,7 @@ import { AppIcon } from '@/components/ui/icons'
import UpdateNoticeModal from './UpdateNoticeModal'
import { useGithubReleaseUpdate } from '@/hooks/common/useGithubReleaseUpdate'
import { Link } from '@/i18n/navigation'
import { buildAuthenticatedHomeTarget } from '@/lib/home/default-route'
export default function Navbar() {
@@ -41,7 +42,7 @@ export default function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-2">
<Link href={{ pathname: session ? '/workspace' : '/' }} className="group">
<Link href={session ? buildAuthenticatedHomeTarget() : { pathname: '/' }} className="group">
<Image
src="/logo-small.png?v=1"
alt={tc('appName')}

View File

@@ -0,0 +1,172 @@
'use client'
/**
* RatioSelector / StyleSelector - 公共选择器组件
* 卡片边框风格:选中时蓝色描边 + 淡色背景 + 加粗文字
*
* 使用场景:首页、项目故事输入页
*/
import { useState, useRef, useEffect } from 'react'
import { AppIcon } from '@/components/ui/icons'
/** 线框比例预览块 */
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
const [w, h] = ratio.split(':').map(Number)
const max = Math.max(w, h)
return (
<div
className={`rounded-md border-2 transition-colors ${
selected ? 'border-[var(--glass-accent-from)]' : 'border-[var(--glass-stroke-strong)]'
}`}
style={{
width: Math.min(size, size * (w / max)),
height: Math.min(size, size * (h / max)),
}}
/>
)
}
export function RatioSelector({
value,
onChange,
options,
getUsage,
}: {
value: string
onChange: (value: string) => void
options: { value: string; label: string; recommended?: boolean }[]
getUsage?: (ratio: string) => string
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectedOption = options.find((o) => o.value === value)
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
>
<div className="flex items-center gap-2.5">
<RatioShape ratio={value} size={18} selected />
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
</div>
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
style={{ minWidth: '300px' }}
>
<div className="grid grid-cols-5 gap-2">
{options.map((option) => {
const isSelected = value === option.value
const usageTag = getUsage?.(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
isSelected
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
}`}
title={usageTag || undefined}
>
<RatioShape ratio={option.value} size={28} selected={isSelected} />
<span className={`text-xs ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
{option.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
export function StyleSelector({
value,
onChange,
options,
}: {
value: string
onChange: (value: string) => void
options: { value: string; label: string; recommended?: boolean }[]
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectedOption = options.find((o) => o.value === value) || options[0]
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
>
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
<div className="grid grid-cols-2 gap-2">
{options.map((option) => {
const isSelected = value === option.value
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex items-center p-3 rounded-xl border text-left transition-all ${
isSelected
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
}`}
>
<span className={`text-sm whitespace-nowrap ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
{option.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -366,13 +366,24 @@ export function SettingsModal({
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualStyle')}</h3>
<div className="max-w-xs">
<StyleSelector
value={artStyle}
onChange={(value) => handleChange(onArtStyleChange)(value)}
options={ART_STYLES}
/>
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualSettings')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('visualStyle')}</label>
<StyleSelector
value={artStyle}
onChange={(value) => handleChange(onArtStyleChange)(value)}
options={ART_STYLES}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('aspectRatio')}</label>
<RatioSelector
value={videoRatio}
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
options={VIDEO_RATIOS}
/>
</div>
</div>
</div>
@@ -491,16 +502,7 @@ export function SettingsModal({
</div>
</div>
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('aspectRatio')}</h3>
<div className="max-w-xs">
<RatioSelector
value={videoRatio}
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
options={VIDEO_RATIOS}
/>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,13 +1,11 @@
'use client'
/**
* 项目配置弹窗专用选择器
* 卡片边框风格:选中时蓝色描边 + 淡色背景 + 加粗文字
*/
import { useEffect, useRef, useState } from 'react'
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
interface RatioIconProps {
ratio: string
size?: number
selected?: boolean
}
import { AppIcon } from '@/components/ui/icons'
interface RatioSelectorProps {
value: string
@@ -21,14 +19,19 @@ interface StyleSelectorProps {
options: Array<{ value: string; label: string }>
}
function RatioIcon({ ratio, size = 24, selected = false }: RatioIconProps) {
// 始终以选中态渲染图标,保证所有比例选项的图标统一为蓝色
/** 线框比例预览块 */
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
const [w, h] = ratio.split(':').map(Number)
const max = Math.max(w, h)
return (
<RatioPreviewIcon
ratio={ratio}
size={size}
selected={selected || true}
variant="surface"
<div
className={`rounded-md border-2 transition-colors ${
selected ? 'border-[var(--glass-accent-from)]' : 'border-[var(--glass-stroke-strong)]'
}`}
style={{
width: Math.min(size, size * (w / max)),
height: Math.min(size, size * (h / max)),
}}
/>
)
}
@@ -56,8 +59,8 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
onClick={() => setIsOpen(!isOpen)}
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
>
<div className="flex items-center gap-3">
<RatioIcon ratio={value} size={20} selected />
<div className="flex items-center gap-2.5">
<RatioShape ratio={value} size={18} selected />
<span className="text-sm text-[var(--glass-text-primary)] font-medium">
{selectedOption?.label || value}
</span>
@@ -68,35 +71,32 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
{isOpen && (
<div
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
style={{ minWidth: '280px' }}
style={{ minWidth: '300px' }}
>
<div className="grid grid-cols-5 gap-2">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors ${
value === option.value
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
: ''
}`}
>
<RatioIcon ratio={option.value} size={28} selected={value === option.value} />
<span
className={`text-xs ${
value === option.value
? 'text-[var(--glass-tone-info-fg)] font-medium'
: 'text-[var(--glass-text-secondary)]'
{options.map((option) => {
const isSelected = value === option.value
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
isSelected
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
}`}
>
{option.label}
</span>
</button>
))}
<RatioShape ratio={option.value} size={28} selected={isSelected} />
<span className={`text-xs ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
{option.label}
</span>
</button>
)
})}
</div>
</div>
)}
@@ -127,32 +127,35 @@ export function StyleSelector({ value, onChange, options }: StyleSelectorProps)
onClick={() => setIsOpen(!isOpen)}
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
>
<div className="flex items-center">
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
</div>
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3">
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
<div className="grid grid-cols-2 gap-2">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex items-center p-3 rounded-lg text-left transition-all ${
value === option.value
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
}`}
>
<span className="font-medium text-sm">{option.label}</span>
</button>
))}
{options.map((option) => {
const isSelected = value === option.value
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value)
setIsOpen(false)
}}
className={`flex items-center p-3 rounded-xl border text-left transition-all ${
isSelected
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
}`}
>
<span className={`text-sm whitespace-nowrap ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
{option.label}
</span>
</button>
)
})}
</div>
</div>
)}