feat(photo-wall): load/upload/delete photos via server, split local layout
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9685336903
commit
a9905b4fa7
@ -8,6 +8,7 @@ import {
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
照片墙 Photo Wall —— 互动式墙面照片展示。
|
||||
@ -46,6 +47,8 @@ interface El {
|
||||
// note
|
||||
text?: string
|
||||
color?: string
|
||||
/** 服务器图片 id(仅 type==='photo'),也是 localStorage 排版的 key */
|
||||
photoId?: string
|
||||
// sticker
|
||||
bg?: string
|
||||
// doodle
|
||||
@ -94,34 +97,58 @@ function transformOf(el: El, extraScale = 1) {
|
||||
return `translate(${el.x}px, ${el.y}px) rotate(${el.rot}deg) scale(${el.scale * extraScale})`
|
||||
}
|
||||
|
||||
/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */
|
||||
interface PhotoLayout {
|
||||
x: number
|
||||
y: number
|
||||
rot: number
|
||||
scale: number
|
||||
z: number
|
||||
w: number
|
||||
caption: string
|
||||
}
|
||||
|
||||
interface SavedState {
|
||||
bgIndex: number
|
||||
els: El[]
|
||||
decorations: El[]
|
||||
photoLayout: Record<string, PhotoLayout>
|
||||
}
|
||||
|
||||
function loadInitial() {
|
||||
let els: El[] = []
|
||||
let decorations: El[] = []
|
||||
let photoLayout: Record<string, PhotoLayout> = {}
|
||||
let bgIndex = 0
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw) as Partial<SavedState>
|
||||
if (Array.isArray(data.els)) els = data.els
|
||||
const data = JSON.parse(raw) as Partial<SavedState> & { els?: El[] }
|
||||
if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex
|
||||
if (Array.isArray(data.decorations)) {
|
||||
decorations = data.decorations
|
||||
} else if (Array.isArray(data.els)) {
|
||||
// 旧结构(照片带 base64 存本地):只保留装饰,丢弃旧照片。
|
||||
decorations = data.els.filter((e) => e.type !== 'photo')
|
||||
}
|
||||
if (data.photoLayout && typeof data.photoLayout === 'object') {
|
||||
photoLayout = data.photoLayout
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 损坏的存档当作空墙 */
|
||||
}
|
||||
const nextId = els.reduce((m, e) => Math.max(m, e.id), 0) + 1
|
||||
const maxZ = Math.max(10, ...els.map((e) => e.z || 10))
|
||||
return { els, bgIndex, nextId, maxZ }
|
||||
const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
|
||||
const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
|
||||
return { decorations, photoLayout, bgIndex, nextId, maxZ }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
|
||||
export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
|
||||
const initial = useMemo(() => loadInitial(), [])
|
||||
|
||||
const [els, setEls] = useState<El[]>(initial.els)
|
||||
const [els, setEls] = useState<El[]>(initial.decorations)
|
||||
// 每张图的本地排版(按服务器图片 id)。runtime 维护,保存时连同装饰一起落盘。
|
||||
const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
|
||||
// 本组件创建的所有 objectURL,卸载时统一释放。
|
||||
const objUrls = useRef<string[]>([])
|
||||
const [bgIndex, setBgIndex] = useState(initial.bgIndex)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
@ -201,7 +228,18 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, els }))
|
||||
const decorations = els.filter((e) => e.type !== 'photo')
|
||||
const photoLayout: Record<string, PhotoLayout> = {}
|
||||
for (const e of els) {
|
||||
if (e.type === 'photo' && e.photoId) {
|
||||
photoLayout[e.photoId] = {
|
||||
x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z,
|
||||
w: e.w ?? 160, caption: e.caption ?? '',
|
||||
}
|
||||
}
|
||||
}
|
||||
layoutRef.current = photoLayout
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout }))
|
||||
setSaveLabel('已保存')
|
||||
} catch {
|
||||
setSaveLabel('保存失败')
|
||||
@ -239,18 +277,72 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const makePhoto = useCallback(
|
||||
(src: string, w: number, h: number) => {
|
||||
const pos = spawnPos(40)
|
||||
const roll = Math.random()
|
||||
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
|
||||
addEl({
|
||||
type: 'photo', src, w, h, x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
|
||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
})
|
||||
},
|
||||
[addEl, spawnPos],
|
||||
)
|
||||
// 登录后从服务器拉本用户图片:取字节为 objectURL,套用本地排版或自动散落。
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { items } = await listPhotos(token)
|
||||
for (const item of items) {
|
||||
let url: string
|
||||
try {
|
||||
url = await fetchPhotoObjectUrl(token, item.id)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!alive) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
objUrls.current.push(url)
|
||||
const layout = layoutRef.current[item.id]
|
||||
// 取自然尺寸算宽高比;无本地排版时随机散落。
|
||||
const dims = await new Promise<{ w: number; h: number }>((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
|
||||
img.onerror = () => resolve({ w: 1, h: 1 })
|
||||
img.src = url
|
||||
})
|
||||
if (!alive) return
|
||||
const ratio = dims.h / dims.w
|
||||
const roll = Math.random()
|
||||
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
|
||||
const pos = spawnPos(40)
|
||||
const base: El = layout
|
||||
? {
|
||||
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
|
||||
w: layout.w, h: Math.round(layout.w * ratio),
|
||||
x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale,
|
||||
z: layout.z, caption: layout.caption,
|
||||
fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
}
|
||||
: {
|
||||
id: idRef.current++, type: 'photo', src: url, photoId: item.id,
|
||||
w: 170, h: Math.round(170 * ratio),
|
||||
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current,
|
||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
}
|
||||
if (layout) zRef.current = Math.max(zRef.current, layout.z)
|
||||
setEls((prev) => [...prev, base])
|
||||
}
|
||||
} catch {
|
||||
if (alive) showToast('图片加载失败(请重新登录或检查网络)')
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
// 仅在挂载/换 token 时跑一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
|
||||
// 卸载时释放所有 objectURL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const u of objUrls.current) URL.revokeObjectURL(u)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const makeNote = useCallback(() => {
|
||||
const pos = spawnPos(20)
|
||||
@ -294,18 +386,43 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const src = ev.target?.result as string
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const w = randInt(140, 200)
|
||||
const h = Math.round(w * (img.height / img.width))
|
||||
makePhoto(src, w, h)
|
||||
// 满 10 张直接拦下
|
||||
const count = elsRef.current.filter((e) => e.type === 'photo').length
|
||||
if (count >= 10) {
|
||||
showToast('每位用户最多 10 张照片')
|
||||
return
|
||||
}
|
||||
img.src = src
|
||||
uploadPhoto(token, src)
|
||||
.then((id) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const w = randInt(140, 200)
|
||||
const h = Math.round(w * (img.height / img.width))
|
||||
const pos = spawnPos(40)
|
||||
const roll = Math.random()
|
||||
const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
|
||||
addEl({
|
||||
type: 'photo', src, photoId: id, w, h,
|
||||
x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
|
||||
caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
|
||||
})
|
||||
}
|
||||
img.src = src
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
const reason = err.message as UploadError
|
||||
showToast(
|
||||
reason === 'full' ? '每位用户最多 10 张照片'
|
||||
: reason === 'too-large' ? '图片过大(上限 8MB)'
|
||||
: reason === 'bad-type' ? '不支持的图片格式'
|
||||
: '上传失败',
|
||||
)
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
},
|
||||
[makePhoto, showToast],
|
||||
[addEl, spawnPos, showToast, token],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -416,6 +533,15 @@ export function PhotoWallPage({ onExit, token: _token }: PhotoWallPageProps) {
|
||||
const recolorNote = (id: number, color: string) =>
|
||||
mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e)))
|
||||
const removeEl = (id: number) => {
|
||||
const target = elsRef.current.find((e) => e.id === id)
|
||||
if (target?.type === 'photo' && target.photoId) {
|
||||
deletePhoto(token, target.photoId).catch(() => {})
|
||||
delete layoutRef.current[target.photoId]
|
||||
if (target.src?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(target.src)
|
||||
objUrls.current = objUrls.current.filter((u) => u !== target.src)
|
||||
}
|
||||
}
|
||||
mutate((p) => p.filter((e) => e.id !== id))
|
||||
nodeRefs.current.delete(id)
|
||||
setMenu(null)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user