@@ -0,0 +1,203 @@
import { useEffect , useState } from 'react'
import {
ArrowDownToLine , Check , Copy , Eye , FileArchive , FileUp ,
KeyRound , LoaderCircle , LogOut , ShieldCheck ,
UploadCloud , Users , X ,
} from 'lucide-react'
import { api , json } from './api'
import { useAppStore } from './store'
const formatBytes = ( bytes ) => {
if ( bytes < 1024 ) return ` ${ bytes } B `
const units = [ 'KB' , 'MB' , 'GB' , 'TB' ]
let value = bytes / 1024
let unit = 0
while ( value >= 1024 && unit < units . length - 1 ) { value /= 1024 ; unit ++ }
return ` ${ value . toFixed ( value >= 10 ? 1 : 2 ) } ${ units [ unit ] } `
}
const formatDate = ( value ) => value ? new Intl . DateTimeFormat ( 'zh-CN' , {
month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' ,
} ) . format ( new Date ( value ) ) : '永久'
function Toast ( { message , onClose } ) {
useEffect ( ( ) => { const timer = setTimeout ( onClose , 3600 ) ; return ( ) => clearTimeout ( timer ) } , [ message , onClose ] )
return < div className = "fixed bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-xl bg-ink px-4 py-3 text-sm text-white shadow-xl" > < Check size = { 16 } / > { message } < / div >
}
function Login ( ) {
const login = useAppStore ( ( s ) => s . login )
const [ form , setForm ] = useState ( { username : '' , password : '' } )
const [ error , setError ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ pickup , setPickup ] = useState ( { hash : '' , code : '' } )
const retrieve = ( preview = false ) => {
const hash = pickup . hash . trim ( )
if ( ! hash ) return
const query = new URLSearchParams ( )
if ( pickup . code ) query . set ( 'code' , pickup . code )
if ( preview ) query . set ( 'preview' , 'true' )
window . open ( ` /uf/ ${ encodeURIComponent ( hash ) } ${ query . size ? ` ? ${ query } ` : '' } ` , '_blank' )
}
const submit = async ( event ) => {
event . preventDefault ( ) ; setBusy ( true ) ; setError ( '' )
try { await login ( form . username , form . password ) }
catch ( e ) { setError ( e . message ) }
finally { setBusy ( false ) }
}
return < main className = "noise min-h-screen px-5 py-8 lg:grid lg:grid-cols-[1.08fr_.92fr] lg:items-center lg:px-[8vw]" >
< section className = "mx-auto max-w-2xl py-8 lg:mx-0" >
< div className = "mb-12 flex items-center gap-3" > < div className = "grid size-11 place-items-center rounded-2xl bg-leaf text-white" > < FileArchive size = { 23 } / > < / div > < div > < p className = "text-xl font-semibold tracking-tight" > ufile < / p > < p className = "text-xs text-ink/45" > 轻量文件共享中心 < / p > < / div > < / div >
< p className = "mb-4 text-sm font-medium uppercase tracking-[.24em] text-leaf" > Send less . Share better . < / p >
< h1 className = "max-w-xl text-5xl font-semibold leading-[1.08] tracking-[-.045em] sm:text-6xl" > 把文件交给链接 , < br / > 把空间留给重要的事 。 < / h1 >
< p className = "mt-6 max-w-lg text-base leading-7 text-ink/55" > 限时保存 、 下载次数与取件码 , 让每一次分享都清晰可控 。 < / p >
< div className = "card mt-10 max-w-lg p-5" >
< div className = "mb-3 flex items-center gap-2 text-sm font-semibold" > < ArrowDownToLine size = { 17 } className = "text-leaf" / > 收到分享 ? 直接取件 < / div >
< div className = "grid gap-2 sm:grid-cols-[1fr_130px_auto]" >
< input className = "field" maxLength = { 8 } placeholder = "8 位文件码" value = { pickup . hash } onChange = { ( e ) => setPickup ( { ... pickup , hash : e . target . value } ) } / >
< input className = "field" inputMode = "numeric" maxLength = { 6 } placeholder = "取件码(可选)" value = { pickup . code } onChange = { ( e ) => setPickup ( { ... pickup , code : e . target . value . replace ( /\D/g , '' ) } ) } / >
< button className = "btn-secondary" onClick = { ( ) => retrieve ( false ) } > 下载 < / button >
< / div >
< / div >
< / section >
< section className = "mx-auto w-full max-w-md py-8 lg:justify-self-end" >
< form className = "card p-7 sm:p-9" onSubmit = { submit } >
< div className = "mb-8" > < div className = "mb-4 grid size-10 place-items-center rounded-xl bg-mint text-leaf" > < KeyRound size = { 20 } / > < / div > < h2 className = "text-2xl font-semibold tracking-tight" > 登录管理空间 < / h2 > < p className = "mt-1.5 text-sm text-ink/50" > 上传文件或管理系统用户 < / p > < / div >
< label className = "label" > 用户名 < / label > < input autoFocus className = "field mb-4" required value = { form . username } onChange = { ( e ) => setForm ( { ... form , username : e . target . value } ) } / >
< label className = "label" > 密码 < / label > < input className = "field" type = "password" required value = { form . password } onChange = { ( e ) => setForm ( { ... form , password : e . target . value } ) } / >
{ error && < p className = "mt-3 text-sm text-red-600" > { error } < / p > }
< button className = "btn-primary mt-6 w-full" disabled = { busy } > { busy ? < LoaderCircle className = "animate-spin" size = { 18 } / > : '登录' } < / button >
< / form >
< / section >
< / main >
}
function ChangePassword ( ) {
const user = useAppStore ( ( s ) => s . user )
const setUser = useAppStore ( ( s ) => s . setUser )
const logout = useAppStore ( ( s ) => s . logout )
const [ form , setForm ] = useState ( { currentPassword : '' , newPassword : '' , confirm : '' } )
const [ error , setError ] = useState ( '' )
const submit = async ( e ) => {
e . preventDefault ( ) ; setError ( '' )
if ( form . newPassword !== form . confirm ) return setError ( '两次输入的新密码不一致' )
try { setUser ( await api ( '/api/auth/password' , json ( 'PUT' , form ) ) ) }
catch ( err ) { setError ( err . message ) }
}
return < main className = "noise grid min-h-screen place-items-center px-5" > < form onSubmit = { submit } className = "card w-full max-w-md p-8" >
< div className = "mb-5 grid size-12 place-items-center rounded-2xl bg-amber-100 text-amber-700" > < KeyRound / > < / div >
< h1 className = "text-2xl font-semibold" > 先设置你的新密码 < / h1 > < p className = "mt-2 text-sm leading-6 text-ink/55" > 你好 , { user . username } 。 临时密码只能用于首次登录 , 新密码至少 8 位 。 < / p >
< label className = "label mt-7" > 当前临时密码 < / label > < input className = "field" type = "password" required value = { form . currentPassword } onChange = { ( e ) => setForm ( { ... form , currentPassword : e . target . value } ) } / >
< label className = "label mt-4" > 新密码 < / label > < input className = "field" type = "password" minLength = { 8 } required value = { form . newPassword } onChange = { ( e ) => setForm ( { ... form , newPassword : e . target . value } ) } / >
< label className = "label mt-4" > 再次输入新密码 < / label > < input className = "field" type = "password" minLength = { 8 } required value = { form . confirm } onChange = { ( e ) => setForm ( { ... form , confirm : e . target . value } ) } / >
{ error && < p className = "mt-3 text-sm text-red-600" > { error } < / p > } < button className = "btn-primary mt-6 w-full" > 完成设置 < / button > < button type = "button" className = "mt-3 w-full py-2 text-sm text-ink/45 hover:text-ink" onClick = { logout } > 退出登录 < / button >
< / form > < / main >
}
function UploadPanel ( ) {
const user = useAppStore ( ( s ) => s . user )
const [ file , setFile ] = useState ( null )
const [ retention , setRetention ] = useState ( '1d' )
const [ downloads , setDownloads ] = useState ( '5' )
const [ customDownloads , setCustomDownloads ] = useState ( '20' )
const [ pickupCode , setPickupCode ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const [ result , setResult ] = useState ( null )
const allowed = user . canUpload || user . canAdvancedUpload
const submit = async ( event ) => {
event . preventDefault ( ) ; if ( ! file ) return
setBusy ( true ) ; setError ( '' )
const data = new FormData ( ) ; data . set ( 'file' , file ) ; data . set ( 'retention' , retention )
data . set ( 'downloads' , downloads === 'custom' ? customDownloads : downloads ) ; data . set ( 'pickupCode' , pickupCode )
try { const uploaded = await api ( '/api/files' , { method : 'POST' , body : data } ) ; setResult ( { ... uploaded , pickupCode } ) ; setFile ( null ) }
catch ( e ) { setError ( e . message ) }
finally { setBusy ( false ) }
}
if ( ! allowed ) return < div className = "card p-8 text-center" > < ShieldCheck className = "mx-auto mb-3 text-ink/25" size = { 32 } / > < h2 className = "font-semibold" > 当前账号没有上传权限 < / h2 > < p className = "mt-1 text-sm text-ink/50" > 请联系用户管理员调整权限 。 < / p > < / div >
if ( result ) return < UploadResult file = { result } onAgain = { ( ) => { setResult ( null ) ; setPickupCode ( '' ) } } / >
return < form className = "card p-5 sm:p-6" onSubmit = { submit } >
< div className = "mb-5 flex items-start justify-between" > < div > < h2 className = "text-lg font-semibold" > 上传新文件 < / h2 > < p className = "mt-1 text-sm text-ink/45" > { user . canAdvancedUpload ? '高级上传 · 不限有效文件数量' : '普通上传 · 最多 10 个有效文件' } < / p > < / div > < UploadCloud className = "text-leaf" / > < / div >
< label className = "group grid min-h-36 cursor-pointer place-items-center rounded-2xl border border-dashed border-ink/20 bg-paper/60 p-5 text-center transition hover:border-leaf/50 hover:bg-mint/25" >
< input className = "hidden" type = "file" onChange = { ( e ) => setFile ( e . target . files [ 0 ] || null ) } / >
< div > < FileUp className = "mx-auto mb-3 text-leaf" / > < p className = "max-w-sm truncate font-medium" > { file ? file . name : '点击选择文件' } < / p > < p className = "mt-1 text-xs text-ink/40" > { file ? formatBytes ( file . size ) : '文件大小取决于服务器可用空间' } < / p > < / div >
< / label >
< div className = "mt-5 grid gap-4 sm:grid-cols-2" >
< div > < label className = "label" > 保留时间 < / label > < select className = "field" value = { retention } onChange = { ( e ) => setRetention ( e . target . value ) } > < option value = "1h" > 1 小时 < / option > < option value = "8h" > 8 小时 < / option > < option value = "1d" > 1 天 < / option > < option value = "3d" > 3 天 < / option > { user . canAdvancedUpload && < option value = "forever" > 永久 < / option > } < / select > < / div >
< div > < label className = "label" > 可访问次数 < / label > < select className = "field" value = { downloads } onChange = { ( e ) => setDownloads ( e . target . value ) } > < option value = "1" > 1 次 < / option > < option value = "3" > 3 次 < / option > < option value = "5" > 5 次 < / option > < option value = "10" > 10 次 < / option > < option value = "custom" > 自定义 < / option > { user . canAdvancedUpload && < option value = "unlimited" > 无限 < / option > } < / select > < / div >
{ downloads === 'custom' && < div > < label className = "label" > 自定义次数 ( 1 – 99 ) < / label > < input className = "field" type = "number" min = "1" max = "99" required value = { customDownloads } onChange = { ( e ) => setCustomDownloads ( e . target . value ) } / > < / div > }
< div className = { downloads === 'custom' ? '' : 'sm:col-span-2' } > < label className = "label" > 6 位数字取件码 ( 可选 ) < / label > < input className = "field" inputMode = "numeric" pattern = "[0-9]{6}" maxLength = { 6 } placeholder = "留空则无需取件码" value = { pickupCode } onChange = { ( e ) => setPickupCode ( e . target . value . replace ( /\D/g , '' ) ) } / > < / div >
< / div >
{ error && < p className = "mt-4 text-sm text-red-600" > { error } < / p > }
< button className = "btn-primary mt-5 w-full" disabled = { ! file || busy } > { busy ? < > < LoaderCircle className = "animate-spin" size = { 18 } / > 正在上传 < / > : < > < UploadCloud size = { 18 } / > 上传并生成链接 < / > } < / button >
< / form >
}
function UploadResult ( { file , onAgain } ) {
const [ copied , setCopied ] = useState ( '' )
const query = new URLSearchParams ( )
if ( file . pickupCode ) query . set ( 'code' , file . pickupCode )
const download = ` ${ location . origin } /uf/ ${ file . hash } ${ query . size ? ` ? ${ query } ` : '' } `
query . set ( 'preview' , 'true' )
const preview = ` ${ location . origin } /uf/ ${ file . hash } ? ${ query } `
const copy = async ( value , type ) => { await navigator . clipboard . writeText ( value ) ; setCopied ( type ) ; setTimeout ( ( ) => setCopied ( '' ) , 1800 ) }
const row = ( title , value , type , icon ) => < div > < label className = "label" > { title } < / label > < div className = "flex gap-2" > < div className = "field min-w-0 flex-1 truncate font-mono text-sm" > { value } < / div > < button className = "btn-secondary shrink-0 !px-3" onClick = { ( ) => copy ( value , type ) } > { copied === type ? < Check size = { 17 } / > : icon } < / button > < / div > < / div >
return < section className = "card mx-auto w-full max-w-2xl p-6 sm:p-8" >
< div className = "mb-7 flex items-start gap-4" > < div className = "grid size-12 shrink-0 place-items-center rounded-2xl bg-mint text-leaf" > < Check size = { 24 } / > < / div > < div className = "min-w-0" > < h2 className = "text-xl font-semibold" > 文件已准备好 < / h2 > < p className = "mt-1 truncate text-sm text-ink/50" > { file . name } · { formatBytes ( file . size ) } < / p > < / div > < / div >
< div className = "space-y-4" > { row ( '文件码' , file . hash , 'hash' , < Copy size = { 17 } / > ) } { row ( '下载链接' , download , 'download' , < Copy size = { 17 } / > ) } { row ( '预览链接' , preview , 'preview' , < Eye size = { 17 } / > ) } { file . pickupCode && row ( '取件码' , file . pickupCode , 'code' , < Copy size = { 17 } / > ) } < / div >
< div className = "mt-5 rounded-xl bg-paper p-4 text-sm text-ink/55" > 有效期 : { formatDate ( file . expiresAt ) } · 访问次数 : { file . maxDownloads == null ? '无限' : ` ${ file . maxDownloads } 次 ` } { file . pickupCode && '· 链接已包含取件码,可按需分开发送' } < / div >
< button className = "btn-primary mt-6 w-full" onClick = { onAgain } > < UploadCloud size = { 18 } / > 继续上传 < / button >
< / section >
}
const emptyUser = { username : '' , canManageUsers : false , canUpload : true , canAdvancedUpload : false }
function UserModal ( { value , onClose , onSaved } ) {
const [ form , setForm ] = useState ( value || emptyUser )
const [ error , setError ] = useState ( '' )
const submit = async ( e ) => {
e . preventDefault ( ) ; setError ( '' )
try { onSaved ( await api ( value ? ` /api/users/ ${ value . id } ` : '/api/users' , json ( value ? 'PUT' : 'POST' , form ) ) ) }
catch ( err ) { setError ( err . message ) }
}
const checkbox = ( key , title , hint ) => < label className = "flex cursor-pointer gap-3 rounded-xl border border-line p-3.5" > < input className = "mt-1 size-4 accent-leaf" type = "checkbox" checked = { form [ key ] } onChange = { ( e ) => setForm ( { ... form , [ key ] : e . target . checked } ) } / > < span > < span className = "block text-sm font-medium" > { title } < / span > < span className = "mt-0.5 block text-xs text-ink/45" > { hint } < / span > < / span > < / label >
return < div className = "fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm" > < form onSubmit = { submit } className = "card w-full max-w-md p-6" > < div className = "flex items-center justify-between" > < h2 className = "text-lg font-semibold" > { value ? '编辑用户' : '创建用户' } < / h2 > < button type = "button" onClick = { onClose } > < X size = { 20 } / > < / button > < / div > < label className = "label mt-5" > 用户名 < / label > < input className = "field" required pattern = "[A-Za-z0-9_.-]{3,32}" value = { form . username } onChange = { ( e ) => setForm ( { ... form , username : e . target . value } ) } / > < div className = "mt-4 space-y-2" > { checkbox ( 'canManageUsers' , '用户管理' , '创建、编辑、重置或删除用户' ) } { checkbox ( 'canUpload' , '普通上传' , '最多保留 10 个有效文件' ) } { checkbox ( 'canAdvancedUpload' , '高级上传' , '涵盖普通上传,并允许永久、无限下载' ) } < / div > { error && < p className = "mt-3 text-sm text-red-600" > { error } < / p > } < div className = "mt-6 flex justify-end gap-2" > < button type = "button" className = "btn-secondary" onClick = { onClose } > 取消 < / button > < button className = "btn-primary" > 保存 < / button > < / div > < / form > < / div >
}
function UsersPanel ( { notify } ) {
const users = useAppStore ( ( s ) => s . users )
const loadUsers = useAppStore ( ( s ) => s . loadUsers )
const [ editing , setEditing ] = useState ( undefined )
const [ modalOpen , setModalOpen ] = useState ( false )
const [ secret , setSecret ] = useState ( null )
useEffect ( ( ) => { loadUsers ( ) } , [ loadUsers ] )
const saved = async ( result ) => { setModalOpen ( false ) ; setEditing ( undefined ) ; await loadUsers ( ) ; if ( result . temporaryPassword ) setSecret ( { title : '用户已创建' , password : result . temporaryPassword } ) ; else notify ( '用户信息已保存' ) }
const reset = async ( user ) => { if ( ! confirm ( ` 重置 ${ user . username } 的密码并注销其所有会话? ` ) ) return ; const result = await api ( ` /api/users/ ${ user . id } /reset-password ` , { method : 'POST' } ) ; setSecret ( { title : ` ${ user . username } 的临时密码 ` , password : result . temporaryPassword } ) }
const remove = async ( user ) => { if ( ! confirm ( ` 删除 ${ user . username } 及其全部文件?此操作不可撤销。 ` ) ) return ; await api ( ` /api/users/ ${ user . id } ` , { method : 'DELETE' } ) ; await loadUsers ( ) ; notify ( '用户及其文件已删除' ) }
return < section className = "card overflow-hidden" > < div className = "flex items-center justify-between border-b border-line px-5 py-4" > < div > < h2 className = "font-semibold" > 用户管理 < / h2 > < p className = "mt-0.5 text-xs text-ink/40" > 权限修改立即生效 < / p > < / div > < button className = "btn-primary !py-2 text-sm" onClick = { ( ) => { setEditing ( undefined ) ; setModalOpen ( true ) } } > 创建用户 < / button > < / div > < div className = "divide-y divide-line" > { users . map ( ( user ) => < div key = { user . id } className = "flex flex-col gap-3 p-5 sm:flex-row sm:items-center" > < div className = "grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf" > { user . username . slice ( 0 , 1 ) . toUpperCase ( ) } < / div > < div className = "min-w-0 flex-1" > < div className = "flex items-center gap-2" > < p className = "font-medium" > { user . username } < / p > < span className = "text-xs text-ink/35" > # { user . id } < / span > { user . mustChangePassword && < span className = "rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700" > 待改密 < / span > } < / div > < p className = "mt-1 text-xs text-ink/45" > { [ user . canManageUsers && '用户管理' , user . canUpload && '普通上传' , user . canAdvancedUpload && '高级上传' ] . filter ( Boolean ) . join ( ' · ' ) || '无权限' } < / p > < / div > < div className = "flex gap-2" > < button className = "btn-secondary !px-3 !py-2 text-xs" onClick = { ( ) => { setEditing ( user ) ; setModalOpen ( true ) } } > 编辑 < / button > < button className = "btn-secondary !px-3 !py-2 text-xs" onClick = { ( ) => reset ( user ) } > 重置密码 < / button > { user . id !== 1 && < button className = "rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick = { ( ) => remove ( user ) } > 删除 < / button > } < / div > < / div > ) } < / div >
{ modalOpen && < UserModal value = { editing } onClose = { ( ) => setModalOpen ( false ) } onSaved = { saved } / > }
{ secret && < div className = "fixed inset-0 z-50 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm" > < div className = "card w-full max-w-md p-7" > < h2 className = "text-lg font-semibold" > { secret . title } < / h2 > < p className = "mt-2 text-sm text-ink/50" > 该密码只显示这一次 , 用户登录后必须修改 。 < / p > < div className = "mt-5 flex items-center justify-between rounded-xl bg-paper p-4 font-mono text-sm" > < span className = "break-all" > { secret . password } < / span > < button onClick = { ( ) => navigator . clipboard . writeText ( secret . password ) } > < Copy size = { 17 } / > < / button > < / div > < button className = "btn-primary mt-5 w-full" onClick = { ( ) => setSecret ( null ) } > 我已保存 < / button > < / div > < / div > }
< / section >
}
function Dashboard ( ) {
const user = useAppStore ( ( s ) => s . user )
const logout = useAppStore ( ( s ) => s . logout )
const [ tab , setTab ] = useState ( 'files' )
const [ toast , setToast ] = useState ( '' )
const notify = ( message ) => setToast ( message )
return < div className = "noise min-h-screen" > < header className = "sticky top-0 z-20 border-b border-line/80 bg-paper/90 backdrop-blur-xl" > < div className = "mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5" > < div className = "flex items-center gap-2.5" > < div className = "grid size-9 place-items-center rounded-xl bg-leaf text-white" > < FileArchive size = { 19 } / > < / div > < span className = "font-semibold" > ufile < / span > < / div > < div className = "flex items-center gap-3" > < span className = "hidden text-sm text-ink/50 sm:block" > { user . username } < / span > < button className = "btn-secondary !p-2.5" title = "退出登录" onClick = { logout } > < LogOut size = { 16 } / > < / button > < / div > < / div > < / header >
< main className = "mx-auto max-w-6xl px-5 py-7" > < div className = "mb-7 flex gap-1 rounded-xl bg-ink/5 p-1 sm:w-fit" > < button className = { ` flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${ tab === 'files' ? 'bg-white shadow-sm' : 'text-ink/50' } ` } onClick = { ( ) => setTab ( 'files' ) } > < FileUp size = { 16 } / > 文件空间 < / button > { user . canManageUsers && < button className = { ` flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${ tab === 'users' ? 'bg-white shadow-sm' : 'text-ink/50' } ` } onClick = { ( ) => setTab ( 'users' ) } > < Users size = { 16 } / > 用户管理 < / button > } < / div >
{ tab === 'files' ? < div className = "mx-auto max-w-2xl" > < UploadPanel / > < / div > : < UsersPanel notify = { notify } / > }
< / main > { toast && < Toast message = { toast } onClose = { ( ) => setToast ( '' ) } / > }
< / div >
}
export default function App ( ) {
const { user , checking , checkSession } = useAppStore ( )
useEffect ( ( ) => { checkSession ( ) } , [ checkSession ] )
if ( checking ) return < div className = "grid min-h-screen place-items-center bg-paper" > < LoaderCircle className = "animate-spin text-leaf" / > < / div >
if ( ! user ) return < Login / >
if ( user . mustChangePassword ) return < ChangePassword / >
return < Dashboard / >
}