This commit is contained in:
freedakgmail
2025-11-24 14:02:34 +08:00
parent b7a8c9ee6e
commit 3d075c6076
941 changed files with 25613 additions and 27641 deletions
+5 -24
View File
@@ -1,41 +1,22 @@
"use client"
import { useState, useEffect } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { db } from "@/lib/db"
import { CircleUser, CircleUserRound } from "lucide-react"
import type { Gender } from "@/types/family"
interface AvatarDisplayProps {
imageId?: string
fallbackUrl?: string
imageUrl?: string // 改为 URL
fallbackText?: string
gender?: Gender
className?: string
}
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, className }: AvatarDisplayProps) {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined)
useEffect(() => {
if (imageId) {
db.images.get(imageId).then((image) => {
if (image) {
const url = URL.createObjectURL(image.blob)
setBlobUrl(url)
return () => URL.revokeObjectURL(url)
}
})
} else {
setBlobUrl(undefined)
}
}, [imageId])
// 如果有头像,显示头像
if (blobUrl) {
export function AvatarDisplay({ imageUrl, fallbackText, gender, className }: AvatarDisplayProps) {
// 如果有头像 URL,显示头像
if (imageUrl) {
return (
<Avatar className={className}>
<AvatarImage src={blobUrl} />
<AvatarImage src={imageUrl} />
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
</Avatar>
)
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-serif font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+2 -2
View File
@@ -32,7 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
className={cn('font-serif leading-none font-semibold', className)}
{...props}
/>
)
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn('font-serif text-muted-foreground text-sm', className)}
{...props}
/>
)
+124
View File
@@ -0,0 +1,124 @@
"use client"
import { useState, useEffect } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { solar2lunar, parseDate } from "@/lib/lunar-calendar"
import { Calendar } from "lucide-react"
interface DateInputWithLunarProps {
label: string
value?: string
isLunar?: boolean
onChange: (value: string) => void
onLunarChange?: (isLunar: boolean) => void
id?: string
showLunarToggle?: boolean // 是否显示公历/农历切换
}
export function DateInputWithLunar({
label,
value,
isLunar = false,
onChange,
onLunarChange,
id,
showLunarToggle = true,
}: DateInputWithLunarProps) {
const [lunarInfo, setLunarInfo] = useState<string>("")
// 当日期改变时,更新农历信息
useEffect(() => {
if (!value) {
setLunarInfo("")
return
}
const date = parseDate(value)
if (!date) {
setLunarInfo("")
return
}
try {
const lunar = solar2lunar(date)
if (lunar) {
setLunarInfo(lunar.toString())
} else {
const year = date.getFullYear()
setLunarInfo(`⚠️ 农历转换仅支持1900-2100年 (当前: ${year}年)`)
}
} catch (error) {
console.error("日期转换错误:", error)
setLunarInfo("")
}
}, [value])
return (
<div className="space-y-3">
<Label htmlFor={id} className="text-base font-medium">
{label}
</Label>
{/* 公历日期输入 */}
<div className="relative">
<Input
id={id}
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="pr-10"
/>
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
{/* 农历对照显示 */}
{lunarInfo && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 space-y-1">
<div className="text-sm font-medium text-amber-900">
</div>
<div className="text-sm text-amber-800">
{lunarInfo}
</div>
</div>
</div>
</div>
)}
{/* 纪念日类型选择 */}
{showLunarToggle && value && lunarInfo && !lunarInfo.includes('⚠️') && onLunarChange && (
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<RadioGroup
value={isLunar ? "lunar" : "solar"}
onValueChange={(val) => onLunarChange(val === "lunar")}
className="flex flex-row gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="solar" id={`${id}-solar`} />
<Label htmlFor={`${id}-solar`} className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="lunar" id={`${id}-lunar`} />
<Label htmlFor={`${id}-lunar`} className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{isLunar
? "💡 将按农历日期计算每年的纪念日,如生日、祭日等"
: "💡 将按公历日期计算每年的纪念日"}
</p>
</div>
)}
</div>
)
}
+22 -34
View File
@@ -12,8 +12,8 @@ import "react-image-crop/dist/ReactCrop.css"
import imageCompression from "browser-image-compression"
interface ImageUploadProps {
value?: string // The image ID
onChange: (imageId: string | undefined) => void
value?: string // The image URL
onChange: (imageUrl: string | undefined) => void
className?: string
}
@@ -27,31 +27,12 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
const imgRef = useRef<HTMLImageElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Load preview if value (imageId) exists
// Load preview if value (URL) exists
useEffect(() => {
let objectUrl: string | undefined
const loadPreview = async () => {
if (!value) {
setPreviewUrl(undefined)
return
}
try {
const image = await db.images.get(value)
if (image) {
objectUrl = URL.createObjectURL(image.blob)
setPreviewUrl(objectUrl)
}
} catch (error) {
console.error("Failed to load image:", error)
}
}
loadPreview()
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
if (value) {
setPreviewUrl(value)
} else {
setPreviewUrl(undefined)
}
}, [value])
@@ -147,20 +128,27 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
imageBlob = await response.blob()
}
const imageId = uuidv4()
await db.images.add({
id: imageId,
blob: imageBlob,
mimeType: imageBlob.type,
createdAt: new Date().toISOString(),
// 上传到服务器
const formData = new FormData()
formData.append('file', imageBlob, 'avatar.jpg')
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
onChange(imageId)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || '上传失败')
}
const { url } = await response.json()
onChange(url)
setShowCropDialog(false)
setImageToCrop(null)
} catch (error) {
console.error("Failed to save image:", error)
alert("图片保存失败")
alert("图片保存失败: " + (error instanceof Error ? error.message : '未知错误'))
} finally {
setIsLoading(false)
}
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'font-serif file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
+1 -1
View File
@@ -13,7 +13,7 @@ function Label({
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
'font-serif flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}