feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+511
View File
@@ -0,0 +1,511 @@
import { useEffect, useRef, useState } from 'react'
import { type Correspondent, type FileItem, type FolderItem, type Tag } from '../api/client'
import { TagRow } from './TagDialog'
import TagChip from './TagChip'
import { FileTypeIcon, FolderGlyph } from './icons'
import FileCover, { FolderCover } from './FileCover'
import { formatBytes } from '../pages/FileBrowser'
/**
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
*
* Lagen bis zum 23.08.2026 unten in FileBrowser.tsx und machten die Seite über
* 1100 Zeilen lang. Sie hängen nur an ihren Eigenschaften, nicht am Zustand der
* Seite – hier stehen sie für sich.
*/
/** Klein in der Liste – dieselbe Form wie auf der Kachel. 24 statt 22 Pixel,
* weil 48/24 glatt aufgeht und die Kanten damit scharf bleiben. */
function FolderIcon({ size = 24, group = false }: { size?: number; group?: boolean }) {
return <FolderGlyph size={size} color={group ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'} />
}
/**
* Zeigt der Nutzer mit dem Finger oder mit einer Maus?
*
* `pointer: coarse` ist die Frage nach der Genauigkeit des Zeigegeräts, nicht
* nach der Bildschirmbreite – ein schmales Fenster auf dem Schreibtisch bleibt
* damit richtig eine Maus, ein Tablet im Querformat richtig ein Finger.
*
* Der Anlass: **Auf dem Handy öffnete gar nichts.** Ordner und Dateien hingen
* an `onDoubleClick`, und den doppelten Klick gibt es auf einem Touchscreen
* praktisch nicht – der Browser meldet zwei einzelne Berührungen, weil man
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
* Mausgeste; am Finger ist er nie angekommen.
*/
function useCoarsePointer(): boolean {
const [coarse, setCoarse] = useState(
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
)
useEffect(() => {
if (typeof matchMedia !== 'function') return
const mq = matchMedia('(pointer: coarse)')
const on = () => setCoarse(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return coarse
}
/**
* Die Öffnen-Geste, je nach Zeigegerät.
*
* Maus: Doppelklick – der einfache Klick bleibt zum Auswählen frei.
* Finger: **einfacher Tipp**. So macht es jede Dateiverwaltung am Telefon,
* und etwas anderes wäre dort nicht zu erraten.
*
* Nur für **Ordner**: Die haben keine Auswahl und keinen zweiten Klick-
* Handler, deshalb genügt hier das Umschalten der Eigenschaft. Dateien
* schreiben ihren Handler selbst aus – warum, steht dort.
*/
function openGesture(coarse: boolean, open: () => void) {
return coarse ? { onClick: open } : { onDoubleClick: open }
}
interface ViewProps {
folders: FolderItem[]
files: FileItem[]
onFolderOpen: (f: FolderItem) => void
onFolderMenu: (e: React.MouseEvent, f: FolderItem) => void
onFileMenu: (e: React.MouseEvent, f: FileItem) => void
/** Öffnen (Doppelklick bzw. Tipp): Vorschau, wenn möglich – sonst Download. */
onFileOpen: (file: FileItem) => void
onFileDownload: (file: FileItem) => void
onFolderDrop: (e: React.DragEvent, folder: FolderItem) => void
dragOverFolder: string | null
onFolderDragOver: (id: string | null) => void
/**
* Datei, zu der gerade aus der Suche gesprungen wurde. Sie wird kurz
* umrandet und in den sichtbaren Bereich geholt.
*
* Ohne das wäre der Sprung in einen Ordner mit fünfzig Dateien wertlos: Man
* landet am richtigen Ort und sucht dann von vorn.
*/
highlightId?: string | null
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit. */
fileTags?: Record<string, Tag[]>
/** Absender je Datei-ID. Anders als die Etiketten höchstens einer. */
fileCorrespondents?: Record<string, Correspondent>
/** Ausgewählte Dateien für den Sammel-Download. */
selected?: Set<string>
/** Umschalten; `range` bei gedrückter Umschalttaste. */
onToggleSelect?: (id: string, range: boolean) => void
}
/** Umrandung der eben angesprungenen Datei. */
const highlightRing = '0 0 0 2px var(--color-primary)'
/**
* Holt die markierte Datei in den sichtbaren Bereich.
*
* In einem eigenen Haken, weil Kachel- und Listenansicht ihn beide brauchen –
* und weil es in einem Callback-Ref bei jedem Neuzeichnen erneut scrollen
* würde, auch wenn sich gar nichts geändert hat.
*/
function useScrollToHighlight(highlightId?: string | null) {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!highlightId || !ref.current) return
ref.current.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [highlightId])
return ref
}
export default function GridView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
const highlightRef = useScrollToHighlight(highlightId)
const coarse = useCoarsePointer()
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 12,
}}>
{folders.map(folder => {
const isOver = dragOverFolder === folder.id
// Ein Gruppenordner lässt sich nicht verschieben – ihn ziehbar zu
// machen, würde eine Bewegung anbieten, die der Server ablehnt.
const isGroupFolder = folder.group_id !== null
return (
<div
key={folder.id}
draggable={!isGroupFolder}
onDragStart={e => {
if (isGroupFolder) { e.preventDefault(); return }
e.dataTransfer.setData('aether-folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
}}
style={{
background: 'var(--color-surface)',
border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
transition: 'border-color 0.15s, transform 0.1s, background 0.1s',
display: 'flex', flexDirection: 'column',
transform: isOver ? 'scale(1.03)' : 'none',
boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none',
}}
{...openGesture(coarse, () => onFolderOpen(folder))}
onContextMenu={e => onFolderMenu(e, folder)}
onMouseEnter={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(-1px)' } }}
onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
onDrop={e => onFolderDrop(e, folder)}
>
<div style={{ height: 100, position: 'relative', overflow: 'hidden' }}>
<FolderCover folder={folder} highlight={isOver} />
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</p>
<p style={{
margin: '2px 0 0', fontSize: 11,
color: isGroupFolder ? 'var(--color-primary)' : 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', gap: 4,
}}>
{isGroupFolder && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)}
{isGroupFolder ? 'Group folder' : 'Folder'}
</p>
</div>
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-file-id', file.id)
e.dataTransfer.effectAllowed = 'move'
}}
ref={file.id === highlightId ? highlightRef : undefined}
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
onClick={e => {
// Am Finger öffnet der Tipp – bzw. schaltet die Auswahl um,
// solange eine läuft. Ohne Strg- und Umschalttaste wäre eine
// Mehrfachauswahl am Telefon sonst gar nicht zu bedienen.
//
// Das steht **hier** und nicht als eigener Handler daneben: In
// JSX gewinnt das zuletzt notierte `onClick`. Ein zweites, per
// Spread eingesetztes wurde von diesem hier stillschweigend
// überschrieben – gemessen: der Tipp auf ein PDF tat nichts,
// während der auf einen Ordner funktionierte.
if (coarse) {
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
onFileOpen(file)
return
}
// **Nur** mit Zusatztaste – der einfache Klick bleibt frei.
//
// Der erste Entwurf wählte auch einfach aus, sobald schon etwas
// markiert war. Das kollidiert mit dem Doppelklick: Dessen erster
// Klick löst dann eine Auswahl aus, und statt die Vorschau zu
// öffnen ändert sich die Markierung. Beim Testen gemessen – aus
// drei Ausgewählten wurden vier, und die Vorschau blieb zu.
//
// Zum Auswählen bleiben das Kästchen und Strg/Umschalt. Das ist
// eindeutig, und der Doppelklick tut wieder genau eine Sache.
if (!onToggleSelect) return
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault()
onToggleSelect(file.id, e.shiftKey)
}
}}
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
// boxShadow statt border: Ein dickerer Rahmen würde die Kachel um
// einen Pixel wachsen lassen und die ganze Reihe verschieben.
boxShadow: selected?.has(file.id)
? '0 0 0 2px var(--color-primary)'
: file.id === highlightId ? highlightRing : undefined,
transition: 'border-color 0.15s, transform 0.1s, box-shadow 0.3s',
display: 'flex', flexDirection: 'column',
}}
onContextMenu={e => onFileMenu(e, file)}
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--color-primary)'
e.currentTarget.style.transform = 'translateY(-1px)'
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
if (pick) pick.style.opacity = '1'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.transform = 'none'
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
}}
>
<div style={{ height: 100, position: 'relative', background: 'var(--color-surface-2)' }}>
<FileCover file={file} />
{/* Kästchen oben links. Erscheint beim Überfahren oder sobald
irgendetwas ausgewählt ist – dauerhaft sichtbar wäre es an
fünfzig Kacheln nur Unruhe, und die meisten Besuche wählen
nichts aus. */}
{onToggleSelect && (
<button
className="aether-pick"
onClick={e => { e.stopPropagation(); onToggleSelect(file.id, e.shiftKey) }}
title="Select"
style={{
position: 'absolute', top: 6, left: 6,
width: 20, height: 20, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(15,23,42,0.65)',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(255,255,255,0.35)'}`,
opacity: selected?.has(file.id) || (selected?.size ?? 0) > 0 ? 1 : 0,
transition: 'opacity 0.12s',
}}
>
{selected?.has(file.id) && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</button>
)}
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={file.name}>
{file.name}
</p>
{/* Eine Zeile unter dem Namen, zwei Zonen: links **wer**, rechts
**was**. Vorher standen Absender, Größe und Aufkleber
nebeneinander in derselben Reihe – drei Angaben in drei
Schriftgraden auf 130 Pixeln, und jede Kachel brach an einer
anderen Stelle um. Genau das machte das Raster unruhig.
Die Größe weicht dabei nach hinten: Sie steht nur noch da, wo
kein Absender steht (Bilder, Downloads), und sonst in der
Listenansicht und unter „Properties". Beim Überfliegen eines
Rasters sucht man den Absender, nicht die Kilobyte. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
margin: '3px 0 0', minWidth: 0, height: 18,
}}>
{fileCorrespondents?.[file.id] ? (
<span
title={fileCorrespondents[file.id].name}
style={{
fontSize: 11, color: 'var(--color-text-muted)', minWidth: 0,
display: 'flex', alignItems: 'center', gap: 4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents[file.id].icon && (
<span style={{ flexShrink: 0 }}>{fileCorrespondents[file.id].icon}</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{fileCorrespondents[file.id].name}
</span>
</span>
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatBytes(file.size)}
</span>
)}
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{
marginLeft: 'auto', minWidth: 0, display: 'flex',
justifyContent: 'flex-end', flexShrink: 0,
}}>
{/* Nur eins, der Rest als „+n": Bei zweien schrumpfen beide
auf „gar…" und „steue…" – zwei unlesbare Aufkleber sagen
weniger als einer lesbarer. Gemessen im Bildschirmfoto. */}
<TagRow tags={fileTags?.[file.id] ?? []} max={1} />
</span>
)}
</div>
</div>
</div>
))}
</div>
)
}
// ── List View ────────────────────────────────────────────────────────────────
export function ListView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFileDownload, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
const highlightRef = useScrollToHighlight(highlightId)
const coarse = useCoarsePointer()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 12px', marginBottom: 4 }}>
<div style={{ width: 32 }} />
<div style={{ flex: 1, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Name</div>
<div style={{ width: 80, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Size</div>
<div style={{ width: 100, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Modified</div>
<div style={{ width: 32 }} />
</div>
{folders.map(folder => {
const isOver = dragOverFolder === folder.id
return (
<div
key={folder.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
}}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 8, cursor: 'grab',
background: isOver ? 'rgba(var(--color-primary-rgb), 0.1)' : 'transparent',
outline: isOver ? '2px solid var(--color-primary)' : 'none',
transition: 'background 0.1s',
}}
{...openGesture(coarse, () => onFolderOpen(folder))}
onContextMenu={e => onFolderMenu(e, folder)}
onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface)' }}
onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
onDrop={e => onFolderDrop(e, folder)}
>
<div style={{ width: 32, display: 'flex', justifyContent: 'center' }}>
<FolderIcon size={24} group={folder.group_id !== null} />
</div>
<div style={{ flex: 1, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>—</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(folder.created_at).toLocaleDateString()}
</div>
<div style={{ width: 32 }} />
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-file-id', file.id)
e.dataTransfer.effectAllowed = 'move'
}}
ref={file.id === highlightId ? highlightRef : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 8,
cursor: 'grab', transition: 'background 0.1s, box-shadow 0.3s',
boxShadow: selected?.has(file.id)
? '0 0 0 2px var(--color-primary)'
: file.id === highlightId ? highlightRing : undefined,
}}
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
onClick={e => {
// Siehe Kachelansicht – ein einziger Handler für beide Geräte.
if (coarse) {
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
onFileOpen(file)
return
}
if (!onToggleSelect) return
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault()
onToggleSelect(file.id, e.shiftKey)
}
}}
onContextMenu={e => onFileMenu(e, file)}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div
style={{ width: 32, display: 'flex', justifyContent: 'center', flexShrink: 0 }}
onClick={e => {
if (!onToggleSelect) return
e.stopPropagation()
onToggleSelect(file.id, e.shiftKey)
}}
title={onToggleSelect ? 'Select' : undefined}
>
{/* In der Liste ersetzt das Kästchen das Symbol, sobald etwas
ausgewählt ist – in einer Zeile ist kein Platz für beides, und
zwei Spalten nur für den seltenen Fall wären Verschwendung. */}
{onToggleSelect && (selected?.has(file.id) || (selected?.size ?? 0) > 0) ? (
<span style={{
width: 18, height: 18, borderRadius: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'transparent',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'var(--color-border)'}`,
cursor: 'pointer',
}}>
{selected?.has(file.id) && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</span>
) : (
<FileTypeIcon mime={file.mime_type} name={file.name} size={22} />
)}
</div>
{/* Name und Aufkleber teilen sich die Spalte: In einer Zeile ist
kein Platz für eine zweite Zeile darunter, und eine eigene Spalte
stünde bei den meisten Dateien leer. */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontSize: 13, color: 'var(--color-text)', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={file.name}>
{file.name}
</span>
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{(fileTags?.[file.id] ?? []).slice(0, 2).map(t => (
<TagChip key={t.id} tag={t} size="sm" />
))}
</span>
)}
</div>
{/* Eigene Spalte statt einer zweiten Zeile: In einer Tabelle gibt
es die Spalte ohnehin schon, und eine leere Zelle ändert die
Zeilenhöhe nicht. */}
<div
className="aether-from-col"
title={fileCorrespondents?.[file.id]?.name}
style={{
width: 130, fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents?.[file.id]
? `${fileCorrespondents[file.id].icon} ${fileCorrespondents[file.id].name}`.trim()
: ''}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{formatBytes(file.size)}
</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(file.updated_at).toLocaleDateString()}
</div>
<button
onClick={e => { e.stopPropagation(); onFileDownload(file) }}
style={{ width: 32, height: 28, border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Download"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
))}
</div>
)
}