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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,193 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||
|
||||
/**
|
||||
* Die Kopfleiste jeder Ansicht.
|
||||
*
|
||||
* Vorher gab es zwei Muster nebeneinander: Dateien, Papierkorb und
|
||||
* Freigabe-Links hatten eine feste Leiste, „Geteilt mit mir", Admin und Profil
|
||||
* dagegen eine große Überschrift im Scrollbereich. Beim Umschalten sprang
|
||||
* dadurch der Inhalt – und schon zwischen den drei Leisten war die Höhe
|
||||
* unterschiedlich, weil sie sich aus dem jeweils höchsten Bedienelement ergab.
|
||||
*
|
||||
* Deshalb hier eine feste Höhe statt einer gewachsenen: Die Leiste ist überall
|
||||
* gleich hoch, egal was darin steht.
|
||||
*/
|
||||
export const headerHeight = 57
|
||||
|
||||
/**
|
||||
* Ab dieser Breite passt alles in eine Zeile.
|
||||
*
|
||||
* Darunter wird umgebrochen statt gequetscht: Auf 390 px stand die Brotkrume
|
||||
* als einzelner Strich da, das Suchfeld schob sich über die Sortierauswahl, der
|
||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
|
||||
interface Props {
|
||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||
icon?: ReactNode
|
||||
title?: string
|
||||
/** Nebenangabe in gedämpfter Schrift, etwa „12 items". */
|
||||
meta?: ReactNode
|
||||
/** Statt Symbol und Titel: eigener Inhalt links, z. B. die Brotkrumen. */
|
||||
left?: ReactNode
|
||||
/** Bedienelemente – sitzen rechts. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function PageHeader({ icon, title, meta, left, children }: Props) {
|
||||
const openSidebar = useSidebarToggle()
|
||||
return (
|
||||
<div className="aether-header" style={{
|
||||
// Feste Höhe nur auf breiten Schirmen (siehe CSS unten). Auf dem Handy
|
||||
// wächst die Leiste auf zwei Zeilen, statt ihren Inhalt zu verstecken.
|
||||
padding: '0 14px',
|
||||
// Statusleiste der installierten App. Die Kopfleiste ist jetzt die
|
||||
// oberste Zeile der Anwendung – vorher lag darüber noch eine zweite.
|
||||
paddingTop: 'var(--safe-top)',
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Die Seitenleiste wird auf dem Handy von hier geöffnet.
|
||||
Vorher stand dafür eine **zweite** Leiste darüber, die nichts weiter
|
||||
enthielt als dieses Symbol und den Namen der Cloud. Gemessen kostete
|
||||
sie 57 der 915 Pixel Höhe – und der Name stand ohnehin schon in der
|
||||
Seitenleiste, die sie öffnete. */}
|
||||
{openSidebar && (
|
||||
<button
|
||||
className="aether-header-menu"
|
||||
onClick={openSidebar}
|
||||
title="Menu"
|
||||
aria-label="Menu"
|
||||
style={{
|
||||
// Kein `display` hier: Das schaltet die Breite unten um, und ein
|
||||
// Inline-Stil schlägt jede Klasse. Genau daran war der Knopf schon
|
||||
// auf dem Desktop zu sehen, obwohl die Regel ihn ausblendet.
|
||||
padding: 6, marginLeft: -6, borderRadius: 8, flexShrink: 0,
|
||||
color: 'var(--color-text-muted)', background: 'none',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{left ?? (
|
||||
<>
|
||||
{icon && (
|
||||
// strokeLinecap/-join hier statt an jedem Symbol: Beide Eigenschaften
|
||||
// vererben sich, und ein Symbol ohne eigene Angabe übernimmt sie
|
||||
// damit. Vorher hatten Papierkorb und Freigabe-Links eckige Enden,
|
||||
// „Geteilt mit mir" runde – nebeneinander wirkt das unruhig.
|
||||
<span style={{
|
||||
display: 'flex', color: 'var(--color-text-muted)', flexShrink: 0,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
}}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{meta && (
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rechte Gruppe: erst die Bedienelemente der Ansicht, dann – durch eine
|
||||
Trennlinie abgesetzt – der Nutzerbereich. Er steht bewusst in *jeder*
|
||||
Ansicht an derselben Stelle; ein Zugang, den man je nach Seite
|
||||
woanders sucht, ist keiner. */}
|
||||
<div className="aether-header-actions" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, minWidth: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Der Nutzerbereich steht **außerhalb** der Bedienelemente.
|
||||
Auf dem Handy gehört er in die Titelzeile nach rechts – dorthin, wo
|
||||
man den eigenen Zugang sucht. Vorher stand er in der Reihe mit
|
||||
„+ Folder" und „Upload" und sah dort aus wie ein weiterer Knopf für
|
||||
eine Handlung, die er nicht ist.
|
||||
|
||||
Umgestellt wird über `order`, nicht über einen zweiten Baum: Zwei
|
||||
Nutzermenüs nebeneinander hießen zwei Klappmenüs, von denen eines
|
||||
immer versteckt ist. */}
|
||||
<div className="aether-header-user" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0,
|
||||
}}>
|
||||
{children && (
|
||||
<span className="aether-header-divider" style={{
|
||||
width: 1, height: 22, background: 'var(--color-border)',
|
||||
margin: '0 2px', flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.aether-header { min-height: ${headerHeight - 1}px; }
|
||||
|
||||
/* Breit: erst die Bedienelemente der Ansicht, dann – durch eine
|
||||
Trennlinie abgesetzt – der Nutzerbereich, ganz rechts. */
|
||||
@media ${wideEnough} {
|
||||
.aether-header-menu { display: none; }
|
||||
.aether-header-actions { order: 2; margin-left: auto; }
|
||||
.aether-header-user { order: 3; }
|
||||
}
|
||||
|
||||
@media not all and ${wideEnough} {
|
||||
.aether-header {
|
||||
padding-top: calc(8px + var(--safe-top));
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
/* Schmal: Nutzerbereich noch in der Titelzeile ganz rechts, die
|
||||
Bedienelemente darunter über die volle Breite.
|
||||
|
||||
Die Umstellung per order ist hier nicht Zierde: Im Baum steht der Nutzerbereich
|
||||
**hinter** den Bedienelementen, und die nehmen sich schmal die
|
||||
volle Breite. Ohne die Umstellung landete er dahinter – also in
|
||||
einer dritten Zeile, allein und rechts. Genau so sah es aus. */
|
||||
.aether-header-menu { display: flex; }
|
||||
.aether-header-user { order: 1; margin-left: auto; }
|
||||
.aether-header-divider { display: none; }
|
||||
/* Nichts wird versteckt – man kommt an alles heran, es steht nur
|
||||
untereinander. */
|
||||
.aether-header-actions {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Das Suchfeld nimmt die Zeile für sich – 220px feste Breite waren
|
||||
der Grund, warum daneben nichts mehr passte. */
|
||||
.aether-header-actions input[type="text"],
|
||||
.aether-header-actions input:not([type]) { width: 100% !important; }
|
||||
}
|
||||
|
||||
@media ${wideEnough} {
|
||||
.aether-header { height: ${headerHeight - 1}px; padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user