feat: add home page and refactor workspace entry UI
This commit is contained in:
@@ -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')}
|
||||
|
||||
172
src/components/selectors/RatioStyleSelectors.tsx
Normal file
172
src/components/selectors/RatioStyleSelectors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user