feat: Implement AppStateModule and AppStateService for application initialization state management

feat: Enhance PhotoAssetService with findPhotosByIds method for batch retrieval of photo manifests

refactor: Move PhotoAsset types to a dedicated file for better organization

feat: Introduce StaticAssetController and StaticShareController for handling static asset requests

feat: Create StaticShareService to manage share page functionality and dynamic data injection

refactor: Consolidate static web controller logic into StaticBaseController for code reuse

fix: Update module imports to reflect new directory structure for AppStateModule

chore: Update pnpm lockfile to include new dependencies
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-18 21:51:48 +08:00
parent 33b01e37ee
commit fcee67b309
32 changed files with 1253 additions and 347 deletions

View File

@@ -1,12 +1,8 @@
'use client'
import {
ArtistNote,
ClosingCTA,
GalleryPreview,
JourneySection,
CreateSpaceCTA,
NocturneHero,
PillarsSection,
} from '~/components/landing/NocturneSections'
export default function Home() {
@@ -14,11 +10,12 @@ export default function Home() {
<div className="relative min-h-screen overflow-hidden bg-[#020202] text-white">
<main className="relative z-10 mx-auto flex w-full max-w-6xl flex-col gap-20 px-4 py-16 sm:px-6 lg:px-0">
<NocturneHero />
<PillarsSection />
<CreateSpaceCTA />
{/* <PillarsSection />
<JourneySection />
<GalleryPreview />
<ArtistNote />
<ClosingCTA />
<ClosingCTA /> */}
</main>
</div>
)

View File

@@ -0,0 +1,198 @@
'use client'
import { AnimatePresence, m } from 'motion/react'
import { useState } from 'react'
import { NocturneButton } from './NocturneButton'
interface CreateSpaceModalProps {
isOpen: boolean
onClose: () => void
}
export const CreateSpaceModal = ({
isOpen,
onClose,
}: CreateSpaceModalProps) => {
const [subdomain, setSubdomain] = useState('')
const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState('')
const handleSubdomainChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, '')
setSubdomain(value)
setError('')
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!subdomain) {
setError('请输入你想要的空间名称')
return
}
if (subdomain.length < 3) {
setError('空间名称至少需要 3 个字符')
return
}
setIsChecking(true)
// TODO: 实际的域名检查和注册逻辑
setTimeout(() => {
setIsChecking(false)
// 这里应该跳转到下一步或显示成功消息
alert(`太棒了!你的专属空间 ${subdomain}.afilmory.art 已创建`)
onClose()
}, 1500)
}
return (
<AnimatePresence>
{isOpen && (
<>
{/* 背景遮罩 */}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-md"
onClick={onClose}
/>
{/* Modal 内容 */}
<div className="fixed inset-0 z-[101] flex items-center justify-center p-4">
<m.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-lg overflow-hidden rounded-[32px] border border-white/10 bg-linear-to-b from-[#0a0a0a] via-[#050505] to-black shadow-[0_30px_120px_rgba(0,0,0,0.9)]"
onClick={(e) => e.stopPropagation()}
>
{/* 背景装饰 */}
<div className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute inset-x-12 inset-y-10 rounded-4xl bg-[radial-gradient(circle_at_top,#2a2a2a,transparent_70%)] blur-3xl" />
</div>
{/* 内容区域 */}
<div className="relative p-8 sm:p-10">
{/* 关闭按钮 */}
<button
type="button"
onClick={onClose}
className="absolute top-6 right-6 flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/60 transition-colors hover:border-white/30 hover:bg-white/10 hover:text-white"
aria-label="关闭"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L11 11M11 1L1 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
{/* 标题区域 */}
<div className="mb-8 text-center">
<p className="text-xs tracking-[0.5em] text-white/40 uppercase">
Create Your Space
</p>
<h2 className="mt-4 font-serif text-3xl leading-tight text-white sm:text-4xl">
</h2>
<p className="mt-4 text-sm leading-relaxed text-white/70">
<br />
</p>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* 输入框 */}
<div className="space-y-3">
<label className="block text-xs tracking-wider text-white/60">
</label>
<div className="relative">
<input
type="text"
value={subdomain}
onChange={handleSubdomainChange}
placeholder="例如myspace"
className="w-full rounded-2xl border border-white/10 bg-white/5 px-5 py-4 pr-32 text-base text-white transition-colors placeholder:text-white/30 focus:border-white/30 focus:bg-white/8 focus:outline-none"
autoFocus
/>
<div className="pointer-events-none absolute top-1/2 right-5 -translate-y-1/2 text-sm text-white/50">
.afilmory.art
</div>
</div>
{error && (
<p className="text-xs text-red-400/80">{error}</p>
)}
</div>
{/* 预览 */}
{subdomain && !error && (
<m.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-white/10 bg-white/5 p-4"
>
<p className="mb-2 text-xs tracking-wider text-white/50">
</p>
<p className="font-mono text-sm text-white">
https://{subdomain}.afilmory.art
</p>
</m.div>
)}
{/* 提示信息 */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<p className="mb-2 text-xs font-medium tracking-wider text-white">
💡
</p>
<ul className="space-y-1.5 text-xs leading-relaxed text-white/60">
<li> 使 (-)</li>
<li> 3 </li>
<li> </li>
</ul>
</div>
{/* 按钮组 */}
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<NocturneButton
type="submit"
disabled={isChecking || !subdomain}
className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
>
{isChecking ? '检查中...' : '创建我的空间'}
</NocturneButton>
<NocturneButton
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
</NocturneButton>
</div>
</form>
</div>
</m.div>
</div>
</>
)}
</AnimatePresence>
)
}

View File

@@ -1,5 +1,8 @@
'use client'
import { useState } from 'react'
import { CreateSpaceModal } from './CreateSpaceModal'
import { NocturneButton } from './NocturneButton'
const pillars = [
@@ -66,7 +69,7 @@ export const NocturneHero = () => {
return (
<section className="relative overflow-hidden rounded-[40px] border border-white/5 bg-linear-to-b from-[#050505] via-[#030303] to-black px-6 py-12 shadow-[0_30px_120px_rgba(0,0,0,0.6)] sm:px-10 sm:py-16">
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute inset-x-12 inset-y-10 rounded-[32px] bg-[radial-gradient(circle_at_top,#1a1a1a,transparent_60%)] blur-3xl" />
<div className="absolute inset-x-12 inset-y-10 rounded-4xl bg-[radial-gradient(circle_at_top,#1a1a1a,transparent_60%)] blur-3xl" />
<div className="absolute top-6 left-1/2 h-48 w-48 -translate-x-1/2 rounded-full bg-linear-to-br from-white/10 via-white/5 to-transparent blur-[90px]" />
</div>
<div className="relative flex flex-col gap-12">
@@ -75,7 +78,7 @@ export const NocturneHero = () => {
<span></span>
</nav>
<div className="space-y-8 text-center">
<p className="text-sm tracking-[0.4em] text-white/50">
<p className="text-sm tracking-[0.4em] text-white/50 uppercase">
Auto Focus · Aperture · Film · Memory
</p>
<h1 className="font-serif text-4xl leading-tight text-white sm:text-5xl lg:text-[4.25rem]">
@@ -83,32 +86,27 @@ export const NocturneHero = () => {
<br />
</h1>
<p className="mx-auto max-w-3xl text-base leading-relaxed text-white/70 sm:text-lg">
Auto Focus Aperture Film Memory
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row sm:gap-6">
<NocturneButton></NocturneButton>
<NocturneButton variant="secondary"></NocturneButton>
<div className="flex justify-center pt-4">
<NocturneButton
onClick={() => {
document
.querySelector('#create-space')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}}
>
</NocturneButton>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1.35fr,1fr]">
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-linear-to-br from-white/10 via-white/0 to-transparent p-6">
<div className="text-xs tracking-[0.4em] text-white/40 uppercase">
Hero Still
</div>
<div className="mt-4 aspect-[4/3] w-full rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.15),rgba(10,10,10,0.8))]">
<div className="flex h-full flex-col items-center justify-center text-center text-white/40">
<span className="text-sm"></span>
<span className="text-xs tracking-[0.3em]">PHOTOGRAPHY</span>
</div>
</div>
<p className="mt-4 text-sm text-white/60">
稿使
</p>
<div className="mt-4 aspect-4/3 w-full overflow-hidden rounded-2xl border border-white/10">
<img
src="https://github.com/Afilmory/assets/blob/main/afilmory-readme.webp?raw=true"
alt="Afilmory Preview"
className="h-full w-full object-cover"
/>
</div>
<div className="flex flex-col justify-between rounded-[28px] border border-white/10 bg-white/5 p-6">
<div>
<p className="text-xs tracking-[0.4em] text-white/40 uppercase">
@@ -129,6 +127,75 @@ export const NocturneHero = () => {
)
}
export const CreateSpaceCTA = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<section
id="create-space"
className="relative overflow-hidden rounded-[40px] border border-white/5 bg-linear-to-b from-[#0a0a0a] via-[#050505] to-black px-6 py-16 shadow-[0_30px_120px_rgba(0,0,0,0.6)] sm:px-10 sm:py-20"
>
<div className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute inset-x-12 inset-y-10 rounded-4xl bg-[radial-gradient(circle_at_center,#2a2a2a,transparent_70%)] blur-3xl" />
</div>
<div className="relative mx-auto max-w-3xl text-center">
<p className="text-xs tracking-[0.6em] text-white/40 uppercase">
Start Your Journey
</p>
<h2 className="mt-6 font-serif text-3xl leading-tight text-white sm:text-4xl lg:text-5xl">
</h2>
<p className="mt-6 text-base leading-relaxed text-white/70 sm:text-lg">
Afilmory
<br className="hidden sm:inline" />
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row sm:gap-6">
<NocturneButton onClick={() => setIsModalOpen(true)}>
</NocturneButton>
</div>
<div className="mt-12 grid gap-4 text-left sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<div className="mb-3 text-2xl">📸</div>
<h3 className="mb-2 text-sm font-medium tracking-wider text-white">
</h3>
<p className="text-xs leading-relaxed text-white/60">
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<div className="mb-3 text-2xl">🎨</div>
<h3 className="mb-2 text-sm font-medium tracking-wider text-white">
</h3>
<p className="text-xs leading-relaxed text-white/60">
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<div className="mb-3 text-2xl">🌐</div>
<h3 className="mb-2 text-sm font-medium tracking-wider text-white">
</h3>
<p className="text-xs leading-relaxed text-white/60">
</p>
</div>
</div>
</div>
</section>
<CreateSpaceModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
)
}
export const PillarsSection = () => {
return (
<section id="chapters" className="space-y-10">
@@ -140,7 +207,7 @@ export const PillarsSection = () => {
Afilmory
</h2>
<p className="mt-3 text-base text-white/70">
Auto FocusApertureFilmMemory
Auto Focus, Aperture, Film, Memory
</p>
</div>

View File

@@ -24,22 +24,28 @@ export const PageHeader = () => {
clsxm(
'pointer-events-auto relative flex items-center justify-between gap-3 overflow-hidden',
radius.xl,
'border pl-4 pr-2 py-2',
'border pl-4 pr-3 py-2.5',
transition.slow,
scrolled
? 'border-white/15 bg-black/80 shadow-[0_20px_60px_rgba(0,0,0,0.5)]'
: 'border-white/20 bg-white/5',
? 'border-white/10 bg-black/90 shadow-[0_8px_32px_rgba(0,0,0,0.6)]'
: 'border-white/8 bg-black/40',
blur['2xl'],
),
[scrolled],
)
const handleGetStarted = () => {
document
.querySelector('#create-space')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return (
<m.header
initial={{ opacity: 0, y: -40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
className="pointer-events-none fixed top-4 left-1/2 z-50 w-full max-w-6xl -translate-x-1/2 pl-4"
className="pointer-events-none fixed top-4 left-1/2 z-50 w-full max-w-6xl -translate-x-1/2 px-4"
>
<m.div
className={headerClasses}
@@ -47,63 +53,60 @@ export const PageHeader = () => {
opacity: headerOpacity,
}}
>
{/* 背景渐变装饰 */}
{/* 背景微光效果 */}
<div
className={clsxm(
'pointer-events-none absolute inset-0',
'pointer-events-none absolute inset-0 opacity-30',
scrolled
? 'bg-linear-to-r from-white/2 via-transparent to-white/2'
: 'bg-linear-to-r from-white/3 via-transparent to-white/3',
? 'bg-linear-to-r from-white/5 via-transparent to-white/5'
: 'bg-linear-to-r from-white/8 via-transparent to-white/8',
)}
/>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.05),transparent_70%)]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.06),transparent_60%)]" />
{/* Logo 区域 */}
<Link
href="/"
className="group relative z-10 flex items-center gap-2 transition-transform hover:scale-105"
className="group relative z-10 flex items-center gap-3 transition-transform hover:scale-[1.02]"
>
<m.div
className={clsxm(
'flex size-9 items-center justify-center',
radius.md,
'border transition-all',
'flex size-10 items-center justify-center',
radius.lg,
'border transition-all duration-300',
scrolled
? 'border-white/25 bg-white/10 shadow-[0_4px_12px_rgba(0,0,0,0.3)]'
: 'border-white/20 bg-black/30',
? 'border-white/20 bg-white/8 shadow-[0_2px_8px_rgba(255,255,255,0.1)]'
: 'border-white/15 bg-white/5',
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<span className="font-serif text-sm tracking-widest text-white">
AF
<span className="font-serif text-base font-medium tracking-wider text-white">
A
</span>
</m.div>
<div className="hidden sm:block">
<p
className={clsxm(
'text-[9px] tracking-[0.4em] uppercase transition-colors',
scrolled ? 'text-white/50' : 'text-white/40',
)}
>
<p className="font-serif text-base font-medium tracking-wide text-white">
Afilmory
</p>
<p className="text-xs font-medium text-white">
{scrolled ? '夜色正在显影' : '暗夜影像策展馆'}
<p
className={clsxm(
'text-[10px] tracking-[0.3em] uppercase transition-colors',
scrolled ? 'text-white/60' : 'text-white/50',
)}
>
Film Archive
</p>
</div>
</Link>
{/* CTA 按钮组 */}
<div className="relative z-10 flex items-center gap-2">
<NocturneButton className="hidden px-5 py-2 text-xs lg:inline-flex">
</NocturneButton>
<NocturneButton
variant="secondary"
className="px-4 py-1.5 text-xs lg:hidden"
onClick={handleGetStarted}
className="px-5 py-2 text-xs"
>
使
</NocturneButton>
</div>
</m.div>

View File

@@ -63,6 +63,7 @@
"react-intersection-observer": "10.0.0",
"react-map-gl": "^8.1.0",
"react-remove-scroll": "2.7.1",
"react-responsive-masonry": "2.7.1",
"react-router": "7.9.5",
"react-scan": "0.4.3",
"react-use-measure": "2.1.7",

45
apps/web/share.html Normal file
View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="Share photos" />
<!-- PWA iOS Safari 配置 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Share" />
<title>Share - Photo Gallery</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet" />
<style>
html {
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
}
html,
body {
position: fixed;
inset: 0;
margin: 0;
padding: 0;
background: #0a0a0a;
color: #ffffff;
overflow-x: hidden;
}
body {
font-family: -apple-system, system-ui, sans-serif;
}
</style>
<script id="config">
window.__CONFIG__ = {}
window.__SITE_CONFIG__ = {}
</script>
<script id="manifest"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/share/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,52 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { useEffect, useState } from 'react'
import { MasonryGallery } from './components/MasonryGallery'
import { PhotoItem } from './components/PhotoItem'
declare global {
interface Window {
__SHARE_DATA__?: PhotoManifestItem | PhotoManifestItem[]
}
}
export function App() {
const [photos, setPhotos] = useState<PhotoManifestItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
try {
const shareData = window.__SHARE_DATA__
if (!shareData) {
setLoading(false)
return
}
// Handle both single photo and array of photos
const loadedPhotos = Array.isArray(shareData) ? shareData : [shareData]
setPhotos(loadedPhotos)
} catch (error) {
console.error('Error loading photos:', error)
} finally {
setLoading(false)
}
}, [])
if (loading) {
return <div className="flex h-screen items-center justify-center bg-[#0a0a0a] text-white">Loading...</div>
}
if (photos.length === 0) {
return <div className="flex h-screen items-center justify-center bg-[#0a0a0a] text-white">No photos found</div>
}
if (photos.length === 1) {
return <PhotoItem photo={photos[0]} className="absolute inset-0 size-full pt-0!" />
}
return (
<div className="h-screen bg-[#0a0a0a] text-white">
<MasonryGallery photos={photos} />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { useMemo } from 'react'
import Masonry from 'react-responsive-masonry'
import { useWindowSize } from 'usehooks-ts'
import { PhotoItem } from './PhotoItem'
interface MasonryGalleryProps {
photos: PhotoManifestItem[]
}
export function MasonryGallery({ photos }: MasonryGalleryProps) {
const { width } = useWindowSize()
const columnsCount = useMemo(() => {
if (width < 600) return 1
if (width < 800) return 2
return 3
}, [width])
return (
<div className="scrollbar-none h-screen overflow-auto">
<Masonry gutter={4} columnsCount={columnsCount}>
{photos.map((photo) => (
<PhotoItem key={photo.id} photo={photo} />
))}
</Masonry>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import { clsxm as cn } from '@afilmory/utils'
import { thumbHashToDataURL } from 'thumbhash'
import {
CarbonIsoOutline,
MaterialSymbolsShutterSpeed,
StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens,
TablerAperture,
} from '~/icons'
const decompressUint8Array = (compressed: string) => {
return Uint8Array.from(compressed.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)))
}
interface PhotoItemProps {
photo: PhotoManifestItem
className?: string
}
export function PhotoItem({ photo, className }: PhotoItemProps) {
// 生成 thumbhash 预览
const thumbHashDataURL = photo.thumbHash ? thumbHashToDataURL(decompressUint8Array(photo.thumbHash)) : null
const ratio = photo.aspectRatio
// 格式化 EXIF 数据
const formatExifData = () => {
const { exif } = photo
// 安全处理:如果 exif 不存在或为空,则返回空对象
if (!exif) {
return {
focalLength35mm: null,
iso: null,
shutterSpeed: null,
aperture: null,
}
}
// 等效焦距 (35mm)
const focalLength35mm = exif.FocalLengthIn35mmFormat
? Number.parseInt(exif.FocalLengthIn35mmFormat)
: exif.FocalLength
? Number.parseInt(exif.FocalLength)
: null
// ISO
const iso = exif.ISO
// 快门速度
const exposureTime = exif.ExposureTime
const shutterSpeed = exposureTime ? `${exposureTime}s` : null
// 光圈
const aperture = exif.FNumber ? `f/${exif.FNumber}` : null
return {
focalLength35mm,
iso,
shutterSpeed,
aperture,
}
}
const exifData = formatExifData()
return (
<button
type="button"
role="link"
onClick={() => {
const siteUrl = window.__SITE_CONFIG__?.url || ''
if (siteUrl) {
window.open(`${siteUrl}/photos/${photo.id}`, '_blank')
}
}}
className={cn('group relative block w-full cursor-pointer overflow-hidden text-left', className)}
style={{
paddingTop: `${100 / ratio}%`,
}}
>
<div className={cn('pointer-events-none absolute inset-0 z-1 flex items-start justify-center')}>
<div className="bg-material-medium mt-4 flex items-center gap-2 rounded-full border border-white/20 px-3 py-1.5 opacity-0 backdrop-blur-[70px] transition-opacity duration-300 group-hover:opacity-100">
<i className="i-mingcute-external-link-line size-4 text-white" />
<span className="text-sm text-white/80">Open in AFilmory</span>
</div>
</div>
<div className="absolute inset-0">
{thumbHashDataURL && (
<img src={thumbHashDataURL} alt={photo.title} className="absolute inset-0 size-full" loading="lazy" />
)}
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="absolute inset-0 size-full object-cover object-center"
loading="lazy"
/>
</div>
{/* 图片信息和 EXIF 覆盖层 */}
<div className="@container pointer-events-none">
{/* 渐变背景 - 独立的层 */}
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
{/* 内容层 - 独立的层以支持 backdrop-filter */}
<div className="absolute inset-x-0 bottom-0 p-4 pb-0 text-white">
{/* 基本信息和标签 section */}
<div className="mb-3 **:duration-300">
<div className="items-center justify-between @[600px]:flex">
<div>
<h3 className="mb-2 truncate text-sm font-medium opacity-0 group-hover:opacity-100">{photo.title}</h3>
{photo.description && (
<p className="mb-2 line-clamp-2 text-sm text-white/80 opacity-0 group-hover:opacity-100">
{photo.description}
</p>
)}
</div>
{/* 基本信息 */}
<div>
<div className="mb-2 flex flex-wrap gap-2 text-xs text-white/80 opacity-0 group-hover:opacity-100">
<span>
{photo.width} × {photo.height}
</span>
<span></span>
<span>{(photo.size / 1024 / 1024).toFixed(1)}MB</span>
</div>
</div>
</div>
{/* Tags */}
{photo.tags && photo.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{photo.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-white/20 px-2 py-0.5 text-xs text-white/90 opacity-0 backdrop-blur-sm group-hover:opacity-100"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2 pb-4 text-xs @[600px]:grid-cols-4">
{exifData.focalLength35mm && (
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-white/70" />
<span className="text-white/90">{exifData.focalLength35mm}mm</span>
</div>
)}
{exifData.aperture && (
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
<TablerAperture className="text-white/70" />
<span className="text-white/90">{exifData.aperture}</span>
</div>
)}
{exifData.shutterSpeed && (
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
<MaterialSymbolsShutterSpeed className="text-white/70" />
<span className="text-white/90">{exifData.shutterSpeed}</span>
</div>
)}
{exifData.iso && (
<div className="flex items-center gap-1.5 rounded-md bg-white/10 px-2 py-1 opacity-0 backdrop-blur-md transition-opacity duration-300 group-hover:opacity-100">
<CarbonIsoOutline className="text-white/70" />
<span className="text-white/90">ISO {exifData.iso}</span>
</div>
)}
</div>
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,16 @@
import '../../styles/index.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.querySelector('#root')
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
}

View File

@@ -164,6 +164,15 @@ const BUILD_FOR_SERVER_SERVE = process.env.BUILD_FOR_SERVER_SERVE === '1'
export default defineConfig(() => {
return {
base: BUILD_FOR_SERVER_SERVE ? '/static/web/' : '/',
// Vite MPA configuration
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
share: path.resolve(__dirname, 'share.html'),
},
},
},
plugins: [
codeInspectorPlugin({
bundler: 'vite',

View File

@@ -1,7 +1,7 @@
import type { HttpMiddleware, OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
import { EventEmitterService, Middleware } from '@afilmory/framework'
import { logger } from 'core/helpers/logger.helper'
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
import { getTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { TenantContextResolver } from 'core/modules/platform/tenant/tenant-context-resolver.service'
import type { Context } from 'hono'

View File

@@ -1,10 +1,10 @@
import { Module } from '@afilmory/framework'
import { AppStateModule } from '../infrastructure/app-state/app-state.module'
import { AuthModule } from '../platform/auth/auth.module'
import { RootAccountProvisioner } from '../platform/auth/root-account.service'
import { TenantModule } from '../platform/tenant/tenant.module'
import { AppInitializationProvider } from './app-initialization.provider'
import { AppStateModule } from './app-state/app-state.module'
@Module({
imports: [AppStateModule, TenantModule, AuthModule],

View File

@@ -1,6 +1,6 @@
import type { OnModuleInit } from '@afilmory/framework'
import { createLogger } from '@afilmory/framework'
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
import { injectable } from 'tsyringe'

View File

@@ -8,7 +8,6 @@ import {
} from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
import { StorageManager } from '@afilmory/builder/storage/index.js'
import type { GitHubConfig, S3Config } from '@afilmory/builder/storage/interfaces.js'
import type { PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
import { EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
@@ -31,40 +30,18 @@ import { injectable } from 'tsyringe'
import { PhotoBuilderService } from '../builder/photo-builder.service'
import { PhotoStorageService } from '../storage/photo-storage.service'
import type {
PhotoAssetListItem,
PhotoAssetManifest,
PhotoAssetRecord,
PhotoAssetSummary,
UploadAssetInput,
} from './photo-asset.types'
import { inferContentTypeFromKey } from './storage.utils'
type PhotoAssetRecord = typeof photoAssets.$inferSelect
const DEFAULT_THUMBNAIL_EXTENSION = {
'image/jpeg': '.jpg',
}[DEFAULT_CONTENT_TYPE]
export interface PhotoAssetListItem {
id: string
photoId: string
storageKey: string
storageProvider: string
manifest: PhotoAssetManifest
syncedAt: string
updatedAt: string
createdAt: string
publicUrl: string | null
size: number | null
syncStatus: PhotoAssetRecord['syncStatus']
}
export interface PhotoAssetSummary {
total: number
synced: number
conflicts: number
pending: number
}
export interface UploadAssetInput {
filename: string
buffer: Buffer
contentType?: string
directory?: string | null
}
const VIDEO_EXTENSIONS = new Set(['mov', 'mp4'])
@@ -173,6 +150,25 @@ export class PhotoAssetService {
return summary
}
async findPhotosByIds(photoIds: string[]): Promise<PhotoManifestItem[]> {
if (photoIds.length === 0) {
return []
}
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const records = await db
.select({
photoId: photoAssets.photoId,
manifest: photoAssets.manifest,
})
.from(photoAssets)
.where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.photoId, photoIds)))
return records.map((record) => record.manifest.data)
}
async deleteAssets(ids: readonly string[], options?: { deleteFromStorage?: boolean }): Promise<void> {
if (ids.length === 0) {
return

View File

@@ -0,0 +1,32 @@
import type { photoAssets } from '@afilmory/db'
export type { PhotoAssetManifest } from '@afilmory/db'
export type PhotoAssetRecord = typeof photoAssets.$inferSelect
export interface PhotoAssetListItem {
id: string
photoId: string
storageKey: string
storageProvider: string
manifest: PhotoAssetManifest
syncedAt: string
updatedAt: string
createdAt: string
publicUrl: string | null
size: number | null
syncStatus: PhotoAssetRecord['syncStatus']
}
export interface PhotoAssetSummary {
total: number
synced: number
conflicts: number
pending: number
}
export interface UploadAssetInput {
filename: string
buffer: Buffer
contentType?: string
directory?: string | null
}

View File

@@ -11,6 +11,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
import { RedisModule } from '../redis/redis.module'
import { AppInitializationModule } from './app/app-initialization.module'
import { AppStateModule } from './app/app-state/app-state.module'
import { BuilderSettingModule } from './configuration/builder-setting/builder-setting.module'
import { SettingModule } from './configuration/setting/setting.module'
import { SiteSettingModule } from './configuration/site-setting/site-setting.module'
@@ -20,7 +21,6 @@ import { FeedModule } from './content/feed/feed.module'
import { OgModule } from './content/og/og.module'
import { PhotoModule } from './content/photo/photo.module'
import { ReactionModule } from './content/reaction/reaction.module'
import { AppStateModule } from './infrastructure/app-state/app-state.module'
import { CacheModule } from './infrastructure/cache/cache.module'
import { DataSyncModule } from './infrastructure/data-sync/data-sync.module'
import { StaticWebModule } from './infrastructure/static-web/static-web.module'

View File

@@ -0,0 +1,24 @@
import { ContextParam, Controller, Get } from '@afilmory/framework'
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import type { Context } from 'hono'
import { StaticBaseController } from './static-base.controller'
import { StaticControllerUtils } from './static-controller.utils'
import { StaticDashboardService } from './static-dashboard.service'
import { StaticWebService } from './static-web.service'
@Controller({ bypassGlobalPrefix: true })
export class StaticAssetController extends StaticBaseController {
constructor(staticWebService: StaticWebService, staticDashboardService: StaticDashboardService) {
super(staticWebService, staticDashboardService)
}
@SkipTenantGuard()
@AllowPlaceholderTenant()
@Get('/*')
async getAsset(@ContextParam() context: Context) {
const response = await this.handleAssetRequest(context, false)
return StaticControllerUtils.applyStaticAssetCors(response)
}
}

View File

@@ -73,6 +73,7 @@ export abstract class StaticAssetService {
const relativeRequestPath = this.extractRelativePath(fullPath)
const target = await this.resolveFile(relativeRequestPath, staticRoot)
this.logger.debug('Resolved static asset request', { fullPath, relativeRequestPath, target })
if (!target) {
return null
}
@@ -226,16 +227,39 @@ export abstract class StaticAssetService {
}
private extractRelativePath(fullPath: string): string {
const index = fullPath.indexOf(this.routeSegment)
const trimmed = fullPath.trim()
if (trimmed.length === 0) {
return ''
}
const index = trimmed.indexOf(this.routeSegment)
if (index === -1) {
return ''
}
if (!this.shouldStripRouteSegment(trimmed, index)) {
return this.stripLeadingSlashes(trimmed)
}
const sliceStart = index + this.routeSegment.length
const remainder = sliceStart < fullPath.length ? fullPath.slice(sliceStart) : ''
const remainder = sliceStart < trimmed.length ? trimmed.slice(sliceStart) : ''
return this.stripLeadingSlashes(remainder)
}
private shouldStripRouteSegment(pathname: string, index: number): boolean {
if (!this.routeSegment) {
return false
}
const matchEnd = index + this.routeSegment.length
const hasValidPrefixBoundary = index === 0 || pathname.charAt(index - 1) === '/'
const nextChar = pathname.charAt(matchEnd)
const hasValidSuffixBoundary =
matchEnd >= pathname.length || nextChar === '/' || nextChar === '?' || nextChar === '#'
return hasValidPrefixBoundary && hasValidSuffixBoundary
}
private stripLeadingSlashes(pathname: string): string {
let result = pathname
while (result.startsWith('/')) {
@@ -249,6 +273,8 @@ export abstract class StaticAssetService {
const normalized = this.normalizePath(decoded)
const candidates = this.buildCandidatePaths(normalized)
this.logger.debug('Static asset resolution candidates', { decoded, normalized, candidates })
for (const candidate of candidates) {
const resolved = await this.tryResolveFile(root, candidate)
if (resolved) {

View File

@@ -0,0 +1,115 @@
import type { Context } from 'hono'
import type { StaticAssetService } from './static-asset.service'
import { StaticControllerUtils } from './static-controller.utils'
import type { StaticDashboardService } from './static-dashboard.service'
import { STATIC_DASHBOARD_BASENAME } from './static-dashboard.service'
import type { StaticWebService } from './static-web.service'
export abstract class StaticBaseController {
constructor(
protected readonly staticWebService: StaticWebService,
protected readonly staticDashboardService: StaticDashboardService,
) {}
protected async handleAssetRequest(context: Context, headOnly: boolean): Promise<Response> {
const service = this.resolveService(context.req.path)
return await this.serve(context, service, headOnly)
}
protected async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise<Response> {
const pathname = context.req.path
const normalizedPath = this.normalizeRequestPath(pathname, service)
const response = await service.handleRequest(normalizedPath, headOnly, {
requestHost: StaticControllerUtils.resolveRequestHost(context),
})
if (response) {
return response
}
return headOnly ? new Response(null, { status: 404 }) : new Response('Not Found', { status: 404 })
}
protected resolveService(pathname: string): StaticAssetService {
if (this.isDashboardPath(pathname)) {
return this.staticDashboardService
}
return this.staticWebService
}
protected normalizeRequestPath(pathname: string, service: StaticAssetService): string {
if (service !== this.staticDashboardService) {
return pathname
}
if (this.isDashboardBasename(pathname)) {
return pathname
}
if (this.isLegacyDashboardPath(pathname)) {
return pathname.replace(/^\/static\/dashboard/, STATIC_DASHBOARD_BASENAME)
}
return pathname
}
protected isDashboardPath(pathname: string): boolean {
return this.isDashboardBasename(pathname) || this.isLegacyDashboardPath(pathname)
}
protected isDashboardBasename(pathname: string): boolean {
return pathname === STATIC_DASHBOARD_BASENAME || pathname.startsWith(`${STATIC_DASHBOARD_BASENAME}/`)
}
protected isLegacyDashboardPath(pathname: string): boolean {
return pathname === '/static/dashboard' || pathname.startsWith('/static/dashboard/')
}
protected isHtmlRoute(pathname: string): boolean {
if (!pathname) {
return true
}
const normalized = pathname.split('?')[0]?.trim() ?? ''
if (!normalized || normalized === '/' || normalized.endsWith('/')) {
return true
}
const lastSegment = normalized.split('/').pop()
if (!lastSegment) {
return true
}
if (lastSegment.endsWith('.html')) {
return true
}
return !lastSegment.includes('.')
}
protected shouldAllowTenantlessDashboardAccess(pathname: string): boolean {
const normalized = this.normalizePathname(pathname)
const welcomePath = `${STATIC_DASHBOARD_BASENAME}/welcome`
return normalized === welcomePath
}
protected normalizePathname(pathname: string): string {
if (!pathname) {
return '/'
}
const [rawPath] = pathname.split('?')
if (!rawPath) {
return '/'
}
const trimmed = rawPath.trim()
if (!trimmed) {
return '/'
}
if (trimmed.length > 1 && trimmed.endsWith('/')) {
return trimmed.replace(/\/+$/, '')
}
return trimmed
}
}

View File

@@ -0,0 +1,102 @@
import { isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
import type { Context } from 'hono'
import type { StaticDashboardService } from './static-dashboard.service'
import { STATIC_DASHBOARD_BASENAME } from './static-dashboard.service'
const TENANT_MISSING_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-missing.html`
const TENANT_RESTRICTED_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-restricted.html`
export const StaticControllerUtils = {
resolveRequestHost(context: Context): string | null {
const forwardedHost = context.req.header('x-forwarded-host')?.trim()
if (forwardedHost) {
return forwardedHost
}
const host = context.req.header('host')?.trim()
if (host) {
return host
}
try {
const url = new URL(context.req.url)
return url.host
} catch {
return null
}
},
cloneResponseWithStatus(response: Response, status: number): Response {
const headers = new Headers(response.headers)
return new Response(response.body, {
status,
headers,
})
},
isReservedTenant({ root = false }: { root?: boolean } = {}): boolean {
const tenantContext = getTenantContext()
if (!tenantContext) {
return false
}
const tenantSlug = tenantContext.tenant.slug?.toLowerCase() ?? null
if (tenantSlug === ROOT_TENANT_SLUG) {
return !!root
}
const requestedSlug = tenantContext.requestedSlug?.toLowerCase() ?? null
if (isPlaceholderTenantContext(tenantContext)) {
if (!requestedSlug) {
return false
}
const candidate = requestedSlug ?? tenantSlug
return isTenantSlugReserved(candidate)
}
if (!tenantSlug) {
return false
}
return isTenantSlugReserved(tenantSlug)
},
shouldRenderTenantMissingPage(): boolean {
const tenantContext = getTenantContext()
return !tenantContext || isPlaceholderTenantContext(tenantContext)
},
async renderTenantMissingPage(dashboardService: StaticDashboardService): Promise<Response> {
const response = await dashboardService.handleRequest(TENANT_MISSING_ENTRY_PATH, false)
if (response) {
return StaticControllerUtils.cloneResponseWithStatus(response, 404)
}
throw new BizException(ErrorCode.COMMON_NOT_FOUND, {
message: 'Workspace unavailable',
})
},
async renderTenantRestrictedPage(dashboardService: StaticDashboardService): Promise<Response> {
const response = await dashboardService.handleRequest(TENANT_RESTRICTED_ENTRY_PATH, false)
if (response) {
return StaticControllerUtils.cloneResponseWithStatus(response, 403)
}
throw new BizException(ErrorCode.COMMON_FORBIDDEN, {
message: 'Workspace access restricted',
})
},
applyStaticAssetCors(response: Response): Response {
response.headers.set('Access-Control-Allow-Origin', '*')
response.headers.set('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type')
return response
},
};

View File

@@ -0,0 +1,40 @@
import { ContextParam, Controller, Get } from '@afilmory/framework'
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import type { Context } from 'hono'
import { StaticBaseController } from './static-base.controller'
import { StaticControllerUtils } from './static-controller.utils'
import { STATIC_DASHBOARD_BASENAME, StaticDashboardService } from './static-dashboard.service'
import { StaticWebService } from './static-web.service'
@Controller({ bypassGlobalPrefix: true })
export class StaticDashboardController extends StaticBaseController {
constructor(staticWebService: StaticWebService, staticDashboardService: StaticDashboardService) {
super(staticWebService, staticDashboardService)
}
@SkipTenantGuard()
@AllowPlaceholderTenant()
@Get(`${STATIC_DASHBOARD_BASENAME}`)
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
const pathname = context.req.path
const isHtmlRoute = this.isHtmlRoute(pathname)
const allowTenantlessAccess = isHtmlRoute && this.shouldAllowTenantlessDashboardAccess(pathname)
const isReservedTenant = StaticControllerUtils.isReservedTenant({ root: false })
if (isHtmlRoute) {
if (isReservedTenant) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (!allowTenantlessAccess && StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
}
return await this.serve(context, this.staticDashboardService, false)
}
}

View File

@@ -0,0 +1,43 @@
import { ContextParam, Controller, createZodSchemaDto, Get, Query } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import type { Context } from 'hono'
import { z } from 'zod'
import { StaticControllerUtils } from './static-controller.utils'
import { StaticDashboardService } from './static-dashboard.service'
import { STATIC_SHARE_ENTRY_PATH,StaticShareService } from './static-share.service'
const shareQuerySchema = z.object({
id: z.string().min(1, 'Photo ID(s) required'),
})
class ShareQueryDto extends createZodSchemaDto(shareQuerySchema) {}
@Controller({ bypassGlobalPrefix: true })
export class StaticShareController {
constructor(
private readonly staticShareService: StaticShareService,
private readonly staticDashboardService: StaticDashboardService,
) {}
@Get('/share/iframe')
async getStaticSharePage(@ContextParam() context: Context, @Query() query: ShareQueryDto) {
if (StaticControllerUtils.isReservedTenant({ root: true })) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
const response = await this.staticShareService.handleRequest(STATIC_SHARE_ENTRY_PATH, false, {
requestHost: StaticControllerUtils.resolveRequestHost(context),
})
if (!response || response.status === 404) {
throw new BizException(ErrorCode.COMMON_NOT_FOUND, {
message: 'Share page not found',
})
}
return await this.staticShareService.decorateSharePageResponse(context, query.id, response)
}
}

View File

@@ -0,0 +1,146 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { PhotoManifestItem } from '@afilmory/builder'
import { SiteSettingService } from 'core/modules/configuration/site-setting/site-setting.service'
import { PhotoAssetService } from 'core/modules/content/photo/assets/photo-asset.service'
import type { Context } from 'hono'
import { DOMParser } from 'linkedom'
import { injectable } from 'tsyringe'
import type { StaticAssetDocument } from './static-asset.service'
import { StaticAssetService } from './static-asset.service'
import { StaticAssetHostService } from './static-asset-host.service'
const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
const STATIC_SHARE_ROUTE_SEGMENT = '/static/web'
export const STATIC_SHARE_ENTRY_PATH = `${STATIC_SHARE_ROUTE_SEGMENT}/share.html`
const STATIC_SHARE_ROOT_CANDIDATES = Array.from(
new Set(
[
resolve(MODULE_DIR, '../../static/web'),
resolve(process.cwd(), 'dist/static/web'),
resolve(process.cwd(), '../dist/static/web'),
resolve(process.cwd(), '../../dist/static/web'),
resolve(process.cwd(), '../../../dist/static/web'),
resolve(process.cwd(), 'static/web'),
resolve(process.cwd(), '../static/web'),
resolve(process.cwd(), '../../static/web'),
resolve(process.cwd(), '../../../static/web'),
resolve(process.cwd(), 'apps/web/dist'),
resolve(process.cwd(), '../apps/web/dist'),
resolve(process.cwd(), '../../apps/web/dist'),
resolve(process.cwd(), '../../../apps/web/dist'),
].filter((entry): entry is string => typeof entry === 'string' && entry.length > 0),
),
)
const DOM_PARSER = new DOMParser()
const STATIC_SHARE_ASSET_LINK_RELS = [
'stylesheet',
'modulepreload',
'preload',
'prefetch',
'icon',
'shortcut icon',
'apple-touch-icon',
'manifest',
]
type TenantSiteConfig = Awaited<ReturnType<SiteSettingService['getSiteConfig']>>
@injectable()
export class StaticShareService extends StaticAssetService {
constructor(
private readonly photoAssetService: PhotoAssetService,
private readonly siteSettingService: SiteSettingService,
private readonly staticAssetHostService: StaticAssetHostService,
) {
super({
routeSegment: STATIC_SHARE_ROUTE_SEGMENT,
rootCandidates: STATIC_SHARE_ROOT_CANDIDATES,
assetLinkRels: STATIC_SHARE_ASSET_LINK_RELS,
loggerName: 'StaticShareService',
staticAssetHostResolver: (requestHost) => this.staticAssetHostService.getStaticAssetHost(requestHost),
})
}
protected override async decorateDocument(_document: StaticAssetDocument): Promise<void> {
// Share page will have data injected dynamically per request
// No default decoration needed here
}
async decorateSharePageResponse(context: Context, photoIds: string, response: Response): Promise<Response> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('text/html')) {
return response
}
const html = await response.text()
const headers = new Headers(response.headers)
// Parse photo IDs (comma-separated)
const ids = photoIds
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) {
return this.createManualHtmlResponse(html, headers, 400)
}
// Find photos from database
const photos = await this.findPhotosByIds(ids)
if (photos.length === 0) {
return this.createManualHtmlResponse(html, headers, 404)
}
const siteConfig = await this.siteSettingService.getSiteConfig()
try {
const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument
this.injectSharePageData(document, photos, siteConfig)
const serialized = document.documentElement.outerHTML
return this.createManualHtmlResponse(serialized, headers, 200)
} catch (error) {
this.logger.error('Failed to inject data for share page', { error })
return this.createManualHtmlResponse(html, headers, response.status)
}
}
private injectSharePageData(
document: StaticAssetDocument,
photos: PhotoManifestItem[],
siteConfig: TenantSiteConfig,
): void {
// Inject config script
const configScript = document.head?.querySelector('#config')
if (configScript) {
const payload = JSON.stringify({
useCloud: true,
})
const siteConfigPayload = JSON.stringify(siteConfig)
configScript.textContent = `window.__CONFIG__ = ${payload};window.__SITE_CONFIG__ = ${siteConfigPayload}`
}
// Inject share data
const manifestScript = document.head?.querySelector('#manifest')
if (manifestScript) {
const shareData = photos.length === 1 ? photos[0] : photos
manifestScript.textContent = `window.__SHARE_DATA__ = ${JSON.stringify(shareData)};`
}
}
private async findPhotosByIds(photoIds: string[]): Promise<PhotoManifestItem[]> {
return await this.photoAssetService.findPhotosByIds(photoIds)
}
private createManualHtmlResponse(html: string, baseHeaders: Headers, status: number): Response {
const headers = new Headers(baseHeaders)
headers.set('content-length', Buffer.byteLength(html, 'utf8').toString())
return new Response(html, { status, headers })
}
}

View File

@@ -1,273 +1,48 @@
import { ContextParam, Controller, Get, Param } from '@afilmory/framework'
import { isTenantSlugReserved } from '@afilmory/utils'
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
import type { Context } from 'hono'
import type { StaticAssetService } from './static-asset.service'
import { STATIC_DASHBOARD_BASENAME, StaticDashboardService } from './static-dashboard.service'
import { StaticBaseController } from './static-base.controller'
import { StaticControllerUtils } from './static-controller.utils'
import { StaticDashboardService } from './static-dashboard.service'
import { StaticWebService } from './static-web.service'
const TENANT_MISSING_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-missing.html`
const TENANT_RESTRICTED_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-restricted.html`
@Controller({ bypassGlobalPrefix: true })
export class StaticWebController {
constructor(
private readonly staticWebService: StaticWebService,
private readonly staticDashboardService: StaticDashboardService,
) {}
@Get('/static/web')
@Get('/static/dashboard')
@SkipTenantGuard()
async getStaticWebRoot(@ContextParam() context: Context) {
return await this.serve(context, this.staticWebService, false)
export class StaticWebController extends StaticBaseController {
constructor(staticWebService: StaticWebService, staticDashboardService: StaticDashboardService) {
super(staticWebService, staticDashboardService)
}
@Get('/')
@Get('/explory')
@SkipTenantGuard()
async getStaticWebIndex(@ContextParam() context: Context) {
if (this.isReservedTenant({ root: true })) {
return await this.renderTenantRestrictedPage()
if (StaticControllerUtils.isReservedTenant({ root: true })) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
const response = await this.serve(context, this.staticWebService, false)
if (response.status === 404) {
return await this.renderTenantMissingPage()
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
return response
}
@Get(`/photos/:photoId`)
async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
if (this.isReservedTenant({ root: true })) {
return await this.renderTenantRestrictedPage()
if (StaticControllerUtils.isReservedTenant({ root: true })) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
const response = await this.serve(context, this.staticWebService, false)
if (response.status === 404) {
return await this.renderTenantMissingPage()
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
}
return await this.staticWebService.decoratePhotoPageResponse(context, photoId, response)
}
@SkipTenantGuard()
@AllowPlaceholderTenant()
@Get(`${STATIC_DASHBOARD_BASENAME}`)
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
const pathname = context.req.path
const isHtmlRoute = this.isHtmlRoute(pathname)
const allowTenantlessAccess = isHtmlRoute && this.shouldAllowTenantlessDashboardAccess(pathname)
const isReservedTenant = this.isReservedTenant({ root: false })
if (isHtmlRoute) {
if (isReservedTenant) {
return await this.renderTenantRestrictedPage()
}
if (!allowTenantlessAccess && this.shouldRenderTenantMissingPage()) {
return await this.renderTenantMissingPage()
}
}
const response = await this.serve(context, this.staticDashboardService, false)
return response
}
@SkipTenantGuard()
@AllowPlaceholderTenant()
@Get('/*')
async getAsset(@ContextParam() context: Context) {
return await this.handleRequest(context, false)
}
private async handleRequest(context: Context, headOnly: boolean): Promise<Response> {
const service = this.resolveService(context.req.path)
return await this.serve(context, service, headOnly)
}
private async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise<Response> {
const pathname = context.req.path
const normalizedPath = this.normalizeRequestPath(pathname, service)
const response = await service.handleRequest(normalizedPath, headOnly, {
requestHost: this.resolveRequestHost(context),
})
if (response) {
return response
}
return headOnly ? new Response(null, { status: 404 }) : new Response('Not Found', { status: 404 })
}
private resolveService(pathname: string): StaticAssetService {
if (this.isDashboardPath(pathname)) {
return this.staticDashboardService
}
return this.staticWebService
}
private normalizeRequestPath(pathname: string, service: StaticAssetService): string {
if (service !== this.staticDashboardService) {
return pathname
}
if (this.isDashboardBasename(pathname)) {
return pathname
}
if (this.isLegacyDashboardPath(pathname)) {
return pathname.replace(/^\/static\/dashboard/, STATIC_DASHBOARD_BASENAME)
}
return pathname
}
private isDashboardPath(pathname: string): boolean {
return this.isDashboardBasename(pathname) || this.isLegacyDashboardPath(pathname)
}
private isDashboardBasename(pathname: string): boolean {
return pathname === STATIC_DASHBOARD_BASENAME || pathname.startsWith(`${STATIC_DASHBOARD_BASENAME}/`)
}
private isLegacyDashboardPath(pathname: string): boolean {
return pathname === '/static/dashboard' || pathname.startsWith('/static/dashboard/')
}
private isHtmlRoute(pathname: string): boolean {
if (!pathname) {
return true
}
const normalized = pathname.split('?')[0]?.trim() ?? ''
if (!normalized || normalized === '/' || normalized.endsWith('/')) {
return true
}
const lastSegment = normalized.split('/').pop()
if (!lastSegment) {
return true
}
if (lastSegment.endsWith('.html')) {
return true
}
return !lastSegment.includes('.')
}
private shouldAllowTenantlessDashboardAccess(pathname: string): boolean {
const normalized = this.normalizePathname(pathname)
const welcomePath = `${STATIC_DASHBOARD_BASENAME}/welcome`
return normalized === welcomePath
}
private normalizePathname(pathname: string): string {
if (!pathname) {
return '/'
}
const [rawPath] = pathname.split('?')
if (!rawPath) {
return '/'
}
const trimmed = rawPath.trim()
if (!trimmed) {
return '/'
}
if (trimmed.length > 1 && trimmed.endsWith('/')) {
return trimmed.replace(/\/+$/, '')
}
return trimmed
}
private resolveRequestHost(context: Context): string | null {
const forwardedHost = context.req.header('x-forwarded-host')?.trim()
if (forwardedHost) {
return forwardedHost
}
const host = context.req.header('host')?.trim()
if (host) {
return host
}
try {
const url = new URL(context.req.url)
return url.host
} catch {
return null
}
}
private isReservedTenant({ root = false }: { root?: boolean } = {}): boolean {
const tenantContext = getTenantContext()
if (!tenantContext) {
return false
}
const tenantSlug = tenantContext.tenant.slug?.toLowerCase() ?? null
if (tenantSlug === ROOT_TENANT_SLUG) {
return !!root
}
const requestedSlug = tenantContext.requestedSlug?.toLowerCase() ?? null
if (isPlaceholderTenantContext(tenantContext)) {
if (!requestedSlug) {
return false
}
const candidate = requestedSlug ?? tenantSlug
return isTenantSlugReserved(candidate)
}
if (!tenantSlug) {
return false
}
return isTenantSlugReserved(tenantSlug)
}
private shouldRenderTenantMissingPage(): boolean {
const tenantContext = getTenantContext()
return !tenantContext || isPlaceholderTenantContext(tenantContext)
}
private async renderTenantMissingPage(): Promise<Response> {
const response = await this.staticDashboardService.handleRequest(TENANT_MISSING_ENTRY_PATH, false)
if (response) {
return this.cloneResponseWithStatus(response, 404)
}
return new Response('Workspace unavailable', { status: 404 })
}
private async renderTenantRestrictedPage(): Promise<Response> {
const response = await this.staticDashboardService.handleRequest(TENANT_RESTRICTED_ENTRY_PATH, false)
if (response) {
return this.cloneResponseWithStatus(response, 403)
}
return new Response('Workspace access restricted', { status: 403 })
}
private cloneResponseWithStatus(response: Response, status: number): Response {
const headers = new Headers(response.headers)
return new Response(response.body, {
status,
headers,
})
}
}

View File

@@ -3,14 +3,18 @@ import { SiteSettingModule } from 'core/modules/configuration/site-setting/site-
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
import { ManifestModule } from 'core/modules/content/manifest/manifest.module'
import { StaticAssetController } from './static-asset.controller'
import { StaticAssetHostService } from './static-asset-host.service'
import { StaticDashboardController } from './static-dashboard.controller'
import { StaticDashboardService } from './static-dashboard.service'
import { StaticShareController } from './static-share.controller'
import { StaticShareService } from './static-share.service'
import { StaticWebController } from './static-web.controller'
import { StaticWebService } from './static-web.service'
@Module({
imports: [SiteSettingModule, SystemSettingModule, ManifestModule],
controllers: [StaticWebController],
providers: [StaticAssetHostService, StaticWebService, StaticDashboardService],
controllers: [StaticShareController, StaticWebController, StaticDashboardController, StaticAssetController],
providers: [StaticAssetHostService, StaticWebService, StaticDashboardService, StaticShareService],
})
export class StaticWebModule {}

View File

@@ -1,8 +1,8 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { AppStateModule } from 'core/modules/app/app-state/app-state.module'
import { SettingModule } from 'core/modules/configuration/setting/setting.module'
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
import { AppStateModule } from 'core/modules/infrastructure/app-state/app-state.module'
import { TenantModule } from '../tenant/tenant.module'
import { AuthConfig } from './auth.config'

View File

@@ -2,8 +2,8 @@ import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import { logger } from 'core/helpers/logger.helper'
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'

View File

@@ -2,7 +2,7 @@ import './tenant.context'
import { Module } from '@afilmory/framework'
import { DatabaseModule } from 'core/database/database.module'
import { AppStateModule } from 'core/modules/infrastructure/app-state/app-state.module'
import { AppStateModule } from 'core/modules/app/app-state/app-state.module'
import { TenantRepository } from './tenant.repository'
import { TenantService } from './tenant.service'

8
pnpm-lock.yaml generated
View File

@@ -737,6 +737,9 @@ importers:
react-remove-scroll:
specifier: 2.7.1
version: 2.7.1(@types/react@19.2.3)(react@19.2.0)
react-responsive-masonry:
specifier: 2.7.1
version: 2.7.1
react-router:
specifier: 7.9.5
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1574,7 +1577,7 @@ importers:
version: 0.16.3(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.3)
unplugin-dts:
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
vite:
specifier: 7.2.2
version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@@ -24393,7 +24396,7 @@ snapshots:
magic-string-ast: 1.0.3
unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.2)
'@volar/typescript': 2.4.23
@@ -24407,6 +24410,7 @@ snapshots:
optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.25.12
rolldown: 1.0.0-beta.49
rollup: 4.53.2
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies: