feat: add mobile folder navigation and template moves
This commit is contained in:
parent
385f5a819a
commit
a11b6e51c8
241
src/App.tsx
241
src/App.tsx
@ -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,26 +599,77 @@ 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)}>
|
||||
<span className={`template-icon ${template.type}`}>{template.type === 'email' ? '✉' : '▤'}</span>
|
||||
<span className="template-card-body">
|
||||
<span className="template-card-topline">
|
||||
<strong>{template.name}</strong>
|
||||
<span className={`status-dot ${template.published_version ? 'published' : 'draft'}`} />
|
||||
<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">
|
||||
<strong>{template.name}</strong>
|
||||
<span className={`status-dot ${template.published_version ? 'published' : 'draft'}`} />
|
||||
</span>
|
||||
<span className="template-key">{template.template_key}</span>
|
||||
<span className="muted">
|
||||
{template.published_version ? `Published v${template.published_version}` : 'Draft only'} · {new Date(template.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="template-key">{template.template_key}</span>
|
||||
<span className="muted">
|
||||
{template.published_version ? `Published v${template.published_version}` : 'Draft only'} · {new Date(template.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="chevron">›</span>
|
||||
</button>
|
||||
<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">
|
||||
|
||||
162
src/refine.css
162
src/refine.css
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user