mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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>
|
||||
)
|
||||
|
||||
198
apps/landing/src/components/landing/CreateSpaceModal.tsx
Normal file
198
apps/landing/src/components/landing/CreateSpaceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 Focus、Aperture、Film、Memory ——
|
||||
Auto Focus, Aperture, Film, Memory ——
|
||||
四个词汇构成名字,也构成观看者进入影像档案的仪式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
45
apps/web/share.html
Normal 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>
|
||||
52
apps/web/src/entries/share/App.tsx
Normal file
52
apps/web/src/entries/share/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
apps/web/src/entries/share/components/MasonryGallery.tsx
Normal file
29
apps/web/src/entries/share/components/MasonryGallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
apps/web/src/entries/share/components/PhotoItem.tsx
Normal file
182
apps/web/src/entries/share/components/PhotoItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
apps/web/src/entries/share/main.tsx
Normal file
16
apps/web/src/entries/share/main.tsx
Normal 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>,
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user