feat: add mobile folder navigation and template moves

This commit is contained in:
Admin 2026-09-23 07:51:21 +00:00
parent 385f5a819a
commit a11b6e51c8
3 changed files with 393 additions and 23 deletions

View File

@ -10,7 +10,8 @@ import type {
TemplateSummary,
TemplateType,
ThemeSummary,
EmailRenderResult
EmailRenderResult,
FolderSummary
} from './types'
type Screen = 'templates' | 'editor' | 'preview' | 'data' | 'use'
@ -84,6 +85,11 @@ function formatDate(value?: string | null) {
export function App({ user }: Props) {
const [screen, setScreen] = useState<Screen>('templates')
const [templates, setTemplates] = useState<TemplateSummary[]>([])
const [folders, setFolders] = useState<FolderSummary[]>([])
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null)
const [createFolderOpen, setCreateFolderOpen] = useState(false)
const [createFolderName, setCreateFolderName] = useState('')
const [moveTemplate, setMoveTemplate] = useState<TemplateSummary | null>(null)
const [active, setActive] = useState<TemplateRecord | null>(null)
const [templateDoc, setTemplateDoc] = useState<TemplateDocument>({ blocks: [] })
const [sampleData, setSampleData] = useState<Record<string, unknown>>({})
@ -120,11 +126,48 @@ export function App({ user }: Props) {
const visibleTemplates = useMemo(() => {
const needle = search.trim().toLowerCase()
return templates.filter(template => {
if ((template.folder_id || null) !== currentFolderId) return false
if (filter !== 'all' && template.type !== filter) return false
if (!needle) return true
return `${template.name} ${template.template_key}`.toLowerCase().includes(needle)
})
}, [templates, filter, search])
}, [templates, filter, search, currentFolderId])
const visibleFolders = useMemo(
() => folders
.filter(folder => (folder.parent_id || null) === currentFolderId)
.sort((a, b) => a.name.localeCompare(b.name)),
[folders, currentFolderId]
)
const folderOptions = useMemo(() => {
const options: Array<FolderSummary & { depth: number }> = []
const walk = (parentId: string | null, depth: number) => {
folders
.filter(folder => (folder.parent_id || null) === parentId)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(folder => {
options.push({ ...folder, depth })
walk(folder.id, depth + 1)
})
}
walk(null, 0)
return options
}, [folders])
const breadcrumbs = useMemo(() => {
const result: FolderSummary[] = []
let id = currentFolderId
const seen = new Set<string>()
while (id && !seen.has(id)) {
seen.add(id)
const folder = folders.find(item => item.id === id)
if (!folder) break
result.unshift(folder)
id = folder.parent_id || null
}
return result
}, [folders, currentFolderId])
const counts = useMemo(() => ({
total: templates.length,
@ -136,6 +179,14 @@ export function App({ user }: Props) {
setTemplates(await api<TemplateSummary[]>('/api/templates'))
}
async function refreshFolders() {
setFolders(await api<FolderSummary[]>('/api/folders'))
}
async function refreshLibrary() {
await Promise.all([refreshTemplates(), refreshFolders()])
}
async function refreshApiKeys() {
setApiKeys(await api<ApiKeySummary[]>('/api/api-keys'))
}
@ -145,7 +196,7 @@ export function App({ user }: Props) {
}
useEffect(() => {
void refreshTemplates().catch(err => setError(String(err)))
void refreshLibrary().catch(err => setError(String(err)))
}, [])
async function saveNow() {
@ -203,14 +254,42 @@ export function App({ user }: Props) {
if (!createName.trim()) return
const created = await api<TemplateRecord>('/api/templates', {
method: 'POST',
body: JSON.stringify({ name: createName.trim(), type: createType })
body: JSON.stringify({ name: createName.trim(), type: createType, folderId: currentFolderId })
})
setCreateOpen(false)
setCreateName('')
await refreshTemplates()
await refreshLibrary()
await openTemplate(created.id)
}
async function createFolder() {
if (!createFolderName.trim()) return
try {
await api<FolderSummary>('/api/folders', {
method: 'POST',
body: JSON.stringify({ name: createFolderName.trim(), parentId: currentFolderId })
})
setCreateFolderName('')
setCreateFolderOpen(false)
await refreshFolders()
} catch (err) {
setError(String(err))
}
}
async function moveTemplateTo(template: TemplateSummary, folderId: string | null) {
try {
await api<TemplateSummary>(`/api/templates/${template.id}/folder`, {
method: 'PATCH',
body: JSON.stringify({ folderId })
})
setMoveTemplate(null)
await refreshTemplates()
} catch (err) {
setError(String(err))
}
}
function addBlock(type: BlockType) {
const block: Block = { id: crypto.randomUUID(), type, props: structuredClone(defaults[type]) }
setTemplateDoc(current => ({ ...current, blocks: [...current.blocks, block] }))
@ -449,7 +528,13 @@ export function App({ user }: Props) {
'/api/template-packs/import',
file
)
await refreshTemplates()
if (currentFolderId) {
await api<TemplateSummary>(`/api/templates/${imported.template.id}/folder`, {
method: 'PATCH',
body: JSON.stringify({ folderId: currentFolderId })
})
}
await refreshLibrary()
await openTemplate(imported.template.id)
} catch (err) {
setError(String(err))
@ -514,11 +599,53 @@ export function App({ user }: Props) {
</div>
</section>
<section className="folder-browser">
<div className="folder-toolbar">
<nav className="folder-breadcrumbs" aria-label="Template folders">
<button className={!currentFolderId ? 'current' : ''} onClick={() => setCurrentFolderId(null)}>Home</button>
{breadcrumbs.map(folder => (
<span key={folder.id}>
<b>›</b>
<button
className={currentFolderId === folder.id ? 'current' : ''}
onClick={() => setCurrentFolderId(folder.id)}
>
{folder.name}
</button>
</span>
))}
</nav>
<button className="new-folder-button" onClick={() => setCreateFolderOpen(true)}>+ Folder</button>
</div>
{!!visibleFolders.length && (
<div className="folder-grid">
{visibleFolders.map(folder => {
const childFolders = folders.filter(item => (item.parent_id || null) === folder.id).length
return (
<button className="folder-card" key={folder.id} onClick={() => setCurrentFolderId(folder.id)}>
<span className="folder-icon">▰</span>
<span>
<strong>{folder.name}</strong>
<small>
{folder.template_count} template{folder.template_count === 1 ? '' : 's'}
{childFolders ? ` · ${childFolders} folder${childFolders === 1 ? '' : 's'}` : ''}
</small>
</span>
<span className="chevron">›</span>
</button>
)
})}
</div>
)}
</section>
{error && <div className="error-banner">{error}</div>}
<section className="template-list">
{visibleTemplates.map(template => (
<button key={template.id} className="template-card refined" onClick={() => void openTemplate(template.id)}>
<article key={template.id} className="template-card refined folder-aware">
<button className="template-card-open" onClick={() => void openTemplate(template.id)}>
<span className={`template-icon ${template.type}`}>{template.type === 'email' ? '✉' : '▤'}</span>
<span className="template-card-body">
<span className="template-card-topline">
@ -532,8 +659,17 @@ export function App({ user }: Props) {
</span>
<span className="chevron">›</span>
</button>
<button
className="template-move-button"
onClick={() => setMoveTemplate(template)}
aria-label={`Move ${template.name}`}
title="Move to folder"
>
↗
</button>
</article>
))}
{!visibleTemplates.length && <div className="empty">No templates match this view.</div>}
{!visibleTemplates.length && !visibleFolders.length && <div className="empty">This folder is empty.</div>}
</section>
<footer className="account-row">
@ -549,7 +685,7 @@ export function App({ user }: Props) {
<span className="sheet-icon">+</span>
<div>
<h2>New template</h2>
<p>Choose the output, then build it from blocks.</p>
<p>Choose the output, then build it from blocks.{currentFolderId ? ` Saving in ${breadcrumbs.at(-1)?.name || 'this folder'}.` : ''}</p>
</div>
</div>
<label>
@ -564,6 +700,67 @@ export function App({ user }: Props) {
</section>
</div>
)}
{createFolderOpen && (
<div className="sheet-backdrop" onClick={() => setCreateFolderOpen(false)}>
<section className="sheet compact" onClick={event => event.stopPropagation()}>
<div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">▰</span>
<div>
<h2>New folder</h2>
<p>{currentFolderId ? `Inside ${breadcrumbs.at(-1)?.name || 'current folder'}` : 'At the library root'}</p>
</div>
</div>
<label>
Name
<input
autoFocus
value={createFolderName}
onChange={event => setCreateFolderName(event.target.value)}
placeholder="Clients"
onKeyDown={event => {
if (event.key === 'Enter') void createFolder()
}}
/>
</label>
<button className="button primary full large" onClick={() => void createFolder()}>Create folder</button>
</section>
</div>
)}
{moveTemplate && (
<div className="sheet-backdrop" onClick={() => setMoveTemplate(null)}>
<section className="sheet compact move-sheet" onClick={event => event.stopPropagation()}>
<div className="grabber" />
<div className="sheet-heading">
<span className="sheet-icon">↗</span>
<div>
<h2>Move template</h2>
<p>{moveTemplate.name}</p>
</div>
</div>
<div className="folder-picker">
<button
className={!moveTemplate.folder_id ? 'active' : ''}
onClick={() => void moveTemplateTo(moveTemplate, null)}
>
<span>⌂</span><strong>Home</strong>
</button>
{folderOptions.map(folder => (
<button
key={folder.id}
className={moveTemplate.folder_id === folder.id ? 'active' : ''}
style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
onClick={() => void moveTemplateTo(moveTemplate, folder.id)}
>
<span>▰</span><strong>{folder.name}</strong>
</button>
))}
</div>
</section>
</div>
)}
</main>
)
}
@ -575,7 +772,7 @@ export function App({ user }: Props) {
return (
<main className="app editor-shell">
<header className="topbar editor-topbar">
<button className="circle" onClick={() => { setScreen('templates'); void refreshTemplates() }}>←</button>
<button className="circle" onClick={() => { setScreen('templates'); void refreshLibrary() }}>←</button>
<div className="title-stack">
<strong>{active?.name}</strong>
<span className="template-meta">

View File

@ -1197,3 +1197,165 @@ body{
line-height:1.5;
}
.integration-note code{color:#42516a}
.folder-browser{
padding:2px 14px 8px;
}
.folder-toolbar{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
margin-bottom:8px;
}
.folder-breadcrumbs{
min-width:0;
display:flex;
align-items:center;
gap:4px;
overflow-x:auto;
scrollbar-width:none;
}
.folder-breadcrumbs::-webkit-scrollbar{display:none}
.folder-breadcrumbs>span{
display:flex;
align-items:center;
gap:4px;
flex:0 0 auto;
}
.folder-breadcrumbs b{
color:#a3adba;
font-size:12px;
}
.folder-breadcrumbs button{
flex:0 0 auto;
border:0;
padding:5px 4px;
background:transparent;
color:#7b8797;
font-size:10px;
font-weight:800;
}
.folder-breadcrumbs button.current{
color:#172033;
}
.new-folder-button{
flex:0 0 auto;
border:1px solid #dce3ea;
border-radius:9px;
padding:7px 9px;
background:white;
color:#42516a;
font-size:9px;
font-weight:800;
}
.folder-grid{
display:grid;
gap:7px;
margin-bottom:8px;
}
.folder-card{
display:grid;
grid-template-columns:38px minmax(0,1fr) 18px;
align-items:center;
gap:10px;
width:100%;
padding:10px 12px;
border:1px solid #dfe5ec;
border-radius:13px;
background:rgba(255,255,255,.86);
color:#263449;
text-align:left;
box-shadow:0 2px 8px rgba(15,23,42,.02);
}
.folder-card>span:nth-child(2){
min-width:0;
display:grid;
gap:2px;
}
.folder-card strong{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:12px;
}
.folder-card small{
color:#8a95a4;
font-size:9px;
}
.folder-icon{
width:38px;
height:34px;
display:grid;
place-items:center;
border-radius:9px;
background:#fff4db;
color:#9a6b13;
font-size:17px;
}
.template-card.refined.folder-aware{
display:grid;
grid-template-columns:minmax(0,1fr) 34px;
gap:4px;
padding:5px;
}
.template-card-open{
min-width:0;
display:grid;
grid-template-columns:42px minmax(0,1fr) 18px;
align-items:center;
gap:12px;
padding:8px;
border:0;
background:transparent;
color:inherit;
text-align:left;
}
.template-move-button{
align-self:center;
width:32px;
height:42px;
border:0;
border-left:1px solid #edf1f5;
background:transparent;
color:#8c98a8;
font-size:16px;
font-weight:800;
}
.folder-picker{
display:grid;
max-height:52vh;
overflow:auto;
border:1px solid #e1e6ed;
border-radius:12px;
}
.folder-picker button{
display:grid;
grid-template-columns:24px minmax(0,1fr);
align-items:center;
gap:7px;
width:100%;
padding:10px 12px;
border:0;
border-bottom:1px solid #edf1f5;
background:#fff;
color:#42516a;
text-align:left;
}
.folder-picker button:last-child{border-bottom:0}
.folder-picker button.active{
background:#eef4ff;
color:#244f8d;
}
.folder-picker strong{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:11px;
}
@media(min-width:760px){
.folder-grid{
grid-template-columns:repeat(3,minmax(0,1fr));
}
}

View File

@ -42,12 +42,13 @@ export type TemplateRecord = {
draft_document: TemplateDocument
sample_data: Record<string, unknown>
published_version?: number | null
folder_id?: string | null
updated_at: string
}
export type TemplateSummary = Pick<
TemplateRecord,
'id' | 'template_key' | 'name' | 'type' | 'status' | 'published_version' | 'updated_at'
'id' | 'template_key' | 'name' | 'type' | 'status' | 'published_version' | 'folder_id' | 'updated_at'
>
export type ApiKeySummary = {
@ -67,3 +68,13 @@ export type ThemeSummary = {
created_at: string
updated_at: string
}
export type FolderSummary = {
id: string
name: string
parent_id?: string | null
template_count: number
created_at: string
updated_at: string
}