Merge: PWA mobile BottomNav + Quick-Add (t-paliad-041)
This commit is contained in:
478
docs/design-pwa-bottom-nav.md
Normal file
478
docs/design-pwa-bottom-nav.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Design: PWA Mobile BottomNav + Drawer
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-04-26
|
||||
**Task:** t-paliad-041
|
||||
**Status:** Design complete — awaiting m's go/no-go before implementation
|
||||
**Reference:** `~/dev/web/docs/pwa-baseline.md` (canonical PWA pattern across m's web surfaces)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Paliad's current navigation is **desktop-first**: a 64px collapsed / 240px
|
||||
expanded left **Sidebar** (with hover/pin) on `≥1024px`, a slide-out
|
||||
drawer with a top-left hamburger on `<1024px`. This works on a laptop. On
|
||||
a phone it does not — the hamburger is a long thumb-stretch and every
|
||||
common action (open Agenda, create a Frist) is two taps deep behind it.
|
||||
|
||||
This design adds a **bottom navigation bar** for phones (`<768px`) per
|
||||
the m-stack PWA baseline:
|
||||
|
||||
- 5-slot fixed bottom bar with thumb-reach icons.
|
||||
- Center slot opens a **slide-up Quick-Add sheet** (Frist / Termin / Projekt).
|
||||
- Right-most slot opens the existing **mobile sidebar drawer** (no new drawer — we reuse what already works).
|
||||
- Auto-hides when the on-screen keyboard opens (`visualViewport` watcher).
|
||||
- Honors `safe-area-inset-bottom` so iOS home-indicator doesn't sit on top of the buttons.
|
||||
|
||||
The desktop Sidebar (≥1024px) is unchanged. Tablets (768-1023px) keep
|
||||
the current hamburger-drawer pattern. Only phones gain BottomNav.
|
||||
|
||||
PWA shell items split into "do now" (cheap, required) and "defer to a
|
||||
follow-up task":
|
||||
|
||||
- **Now:** `viewport-fit=cover`, `theme-color`, `apple-mobile-web-app-*`
|
||||
meta tags so iOS draws under the notch and `safe-area-inset` actually
|
||||
has values.
|
||||
- **Later (separate task):** `manifest.json` + icon assets, service worker,
|
||||
add-to-home-screen prompt UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why These Choices (HLC Patent Lawyer Perspective)
|
||||
|
||||
The user is a litigator-in-the-hallway — between meetings, on the train,
|
||||
in court anteroom. The phone use-case is overwhelmingly **read** rather
|
||||
than create:
|
||||
|
||||
1. *"What's coming up this week?"* → Agenda, Dashboard
|
||||
2. *"What's the status on this matter?"* → Projekte detail
|
||||
3. *"Quickly capture a Frist I just got told about"* → create Frist
|
||||
4. *"What's the Frist for replying to office action X?"* → Fristenrechner (rare on phone)
|
||||
5. *"Settings / Glossar / Kostenrechner"* → desk activities, rare on phone
|
||||
|
||||
The bottom slots therefore optimise for read-heavy, with a single
|
||||
prominent capture path in the center. Tools/Wissen/Settings live in the
|
||||
drawer because phone use of those is rare and a one-tap detour is fine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Slot Layout
|
||||
|
||||
**5 slots, decided:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [page content] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [🏠] [📁] ╔══[+]══╗ [📅] [☰] │
|
||||
│ Start Projekte Anlegen Agenda Menü │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↑ ↑ ↑ ↑ ↑
|
||||
Dash Projekte Quick-Add Agenda Drawer
|
||||
(/dashboard) (/projects) (sheet) (/agenda) (toggle)
|
||||
```
|
||||
|
||||
| Slot | Label DE | Label EN | Target | Icon (reuse from Sidebar.tsx) |
|
||||
|------|----------|----------|--------|-------------------------------|
|
||||
| 1 | Start | Home | `/dashboard` | `ICON_GAUGE` |
|
||||
| 2 | Projekte | Projects | `/projects` | `ICON_FOLDER` |
|
||||
| 3 | Anlegen | New | (opens sheet) | `ICON_PLUS` (new) |
|
||||
| 4 | Agenda | Agenda | `/agenda` | `ICON_AGENDA` |
|
||||
| 5 | Menü | Menu | (opens drawer) | `ICON_MENU` |
|
||||
|
||||
### Why Dashboard + Agenda over Dashboard + Fristen
|
||||
|
||||
Initial brief proposed `[Dashboard / Projekte / + / Fristen / Menu]` or
|
||||
`[... / Agenda / Menu]`. **Agenda wins** because:
|
||||
|
||||
- Agenda merges Fristen *and* Termine into one date-sorted timeline
|
||||
(shipped in t-paliad-030). On a phone you almost never want one but
|
||||
not the other — you want "what's next".
|
||||
- Fristen is reachable from Agenda items (each row deep-links to
|
||||
`/deadlines/{id}`) and from the drawer.
|
||||
- Dashboard already gives the high-level "traffic light" overview
|
||||
(overdue / today / week / later) — Agenda gives the actionable list
|
||||
underneath. Pairing them in the BottomNav covers ~80% of phone reads.
|
||||
|
||||
### Why Projekte not Termine
|
||||
|
||||
Termine alone is too narrow for a top-level slot. A patent lawyer's
|
||||
mental model is "I'm working on matter X" — Projekte is the natural
|
||||
hub. Termine is reachable from a project's detail page or from Agenda.
|
||||
|
||||
### Active-state highlighting
|
||||
|
||||
Same rule the Sidebar already uses (`navItem` active logic): a slot is
|
||||
active when its `href` is a prefix of `currentPath`. So `/projects/abc`
|
||||
keeps the Projekte slot lit, `/deadlines/{id}` lights nothing in
|
||||
BottomNav (deadlines aren't a top-level slot — that's fine, the
|
||||
breadcrumb still works).
|
||||
|
||||
---
|
||||
|
||||
## 4. Center Slot: Quick-Add Sheet
|
||||
|
||||
A **slide-up sheet** (not a navigation) with three options:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ───── │ ← drag-handle
|
||||
│ │
|
||||
│ 📅 Frist anlegen › │ → /deadlines/new
|
||||
│ 🗓 Termin anlegen › │ → /appointments/new
|
||||
│ 📁 Projekt anlegen › │ → /projects/new
|
||||
│ │
|
||||
│ [Abbrechen] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why a sheet, not a deep-link
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **Sheet w/ 3 options** ✅ | One predictable place; works on every page; matches "primary capture/add" idiom from baseline doc | One extra tap vs deep-link |
|
||||
| Always `/deadlines/new` | Zero-tap deadline creation | Wrong default ~30% of the time (Termin/Projekt also frequent); no escape if user wanted Termin |
|
||||
| Context-aware (per page) | Smart defaults | Surprising — same button does different things on different pages; harder to learn |
|
||||
|
||||
The sheet is also where new capture types can be added later (Quick
|
||||
Note, Voice memo) without redesign. Cheap to extend.
|
||||
|
||||
### Sheet mechanics (mvp — does *not* fully copy otto-pwa)
|
||||
|
||||
- Native `<dialog>` element via `dialog.showModal()`.
|
||||
- Slide-up via CSS `transform: translateY(100%) → translateY(0)`,
|
||||
`transition: transform 220ms ease-out`.
|
||||
- Backdrop tap dismisses (`dialog::backdrop` click handler).
|
||||
- ESC closes (native `<dialog>` behavior).
|
||||
- **Drag-to-dismiss is NOT in v1.** The full otto-pwa pointer-event
|
||||
pattern (handle hit-area + pointermove transform + 120px threshold)
|
||||
is great but adds ~80 lines for a phone-only flourish. Ship without
|
||||
it; if m wants it, a follow-up task adds it copying otto-pwa
|
||||
`voice-modal` verbatim.
|
||||
- `max-height: 60vh` (we have only 3 rows; 92vh from the baseline doc is
|
||||
for sheets that contain scrollable lists).
|
||||
|
||||
### Tapping a sheet row
|
||||
|
||||
Just navigates: `window.location.href = "/deadlines/new"` etc. The
|
||||
existing `/deadlines/new`, `/appointments/new`, `/projects/new` pages
|
||||
already work on mobile (form layout is single-column). No new endpoints.
|
||||
|
||||
Note: `/projects/new` requires admin in current implementation — for a
|
||||
non-admin user, that row should be hidden (read `window.__PALIAD_ME__`
|
||||
or whatever the page exposes; if not exposed, just always show and let
|
||||
the destination page error gracefully — m's call).
|
||||
|
||||
---
|
||||
|
||||
## 5. Drawer: Reuse What's There
|
||||
|
||||
**No new drawer.** The existing `Sidebar.tsx` already renders into a
|
||||
fixed-left aside that, at `<1024px`, is `transform: translateX(-100%)`
|
||||
by default and slides to `translateX(0)` when class `mobile-open` is
|
||||
toggled. The hamburger button + `.sidebar-overlay` already do the open
|
||||
mechanics.
|
||||
|
||||
The BottomNav `[Menü]` slot wires into the same toggle that the
|
||||
hamburger uses — they call the same `toggleMobileSidebar()`.
|
||||
|
||||
### Hamburger fate
|
||||
|
||||
At `<768px` (BottomNav visible): the existing top-left hamburger is
|
||||
**hidden** (the BottomNav menu slot does the same job, in a thumb-reach
|
||||
spot). At `768-1023px`: hamburger stays visible, BottomNav stays
|
||||
hidden — current behavior preserved.
|
||||
|
||||
### What's in the drawer
|
||||
|
||||
It's the existing Sidebar — Dashboard, Übersicht (Dashboard, Agenda,
|
||||
Team), Arbeit (Projekte, Fristen, Termine), Werkzeuge, Wissen,
|
||||
Ressourcen, Einstellungen, plus the bottom block (Neuigkeiten, invite,
|
||||
DE/EN, Logout). Nothing duplicated, nothing pruned. Items already in
|
||||
the BottomNav (Dashboard, Projekte, Agenda) also still appear in the
|
||||
drawer — that's intentional, the drawer is the canonical map.
|
||||
|
||||
### Drawer trigger options considered
|
||||
|
||||
| Trigger | Verdict |
|
||||
|---|---|
|
||||
| BottomNav `[Menü]` slot ✅ | Standard, discoverable, thumb-reach |
|
||||
| Top-left hamburger (legacy) | Hidden on phones; lives on for tablets |
|
||||
| Edge-swipe from left | **No** — conflicts with project-detail tabs that already overflow-scroll horizontally on mobile |
|
||||
| ESC closes | Already implemented via `closeMobile()` |
|
||||
|
||||
Matches mBrian/otto pattern: button-triggered, no swipe.
|
||||
|
||||
---
|
||||
|
||||
## 6. Breakpoints
|
||||
|
||||
```
|
||||
≥1024px : Desktop sidebar (hover-expand, pin)
|
||||
768-1023px : Slide-out drawer + top-left hamburger (current behavior)
|
||||
<768px : Slide-out drawer + BottomNav (hamburger hidden)
|
||||
```
|
||||
|
||||
Two distinct thresholds because they answer different questions:
|
||||
|
||||
- **1024px** = "is there room for a persistent collapsed sidebar?"
|
||||
- **768px** = "is this a phone — do we need a thumb-reach bar?"
|
||||
|
||||
The existing `1023px` breakpoint stays. We add a new `767px` breakpoint
|
||||
specifically for showing/hiding BottomNav and hiding the legacy
|
||||
hamburger.
|
||||
|
||||
The pwa-baseline doc says 768px throughout — that's the BottomNav
|
||||
breakpoint. The doc doesn't mandate the 1024 sidebar threshold; that's
|
||||
a paliad-specific affordance worth preserving.
|
||||
|
||||
---
|
||||
|
||||
## 7. Visual Spec
|
||||
|
||||
### Bar dimensions
|
||||
|
||||
- Height: `56px` (`--bottom-nav-height`, matches baseline doc).
|
||||
- Background: `var(--color-surface)` (`#ffffff`).
|
||||
- Top border: `1px solid var(--color-border)`.
|
||||
- Box-shadow: subtle upward `0 -1px 3px rgba(0,0,0,0.04)` — looks
|
||||
attached to the screen edge, not floating.
|
||||
- Position: `fixed; bottom: 0; left: 0; right: 0;`
|
||||
- Padding-bottom: `env(safe-area-inset-bottom)` — additive to the 56px,
|
||||
so on iPhone X+ the bar effectively grows to account for the home
|
||||
indicator without overlapping it.
|
||||
- Width: 100%, slots `flex: 1`.
|
||||
- Z-index: `30` (above content, below sidebar overlay z=35 so the drawer
|
||||
always covers BottomNav, below modals z=100).
|
||||
|
||||
### Slot
|
||||
|
||||
- 56px tall, full-width slot, vertical icon (~22px) + label (10-11px).
|
||||
- Active slot: lime accent `var(--color-accent)` icon + label, with a
|
||||
thin lime top-bar (3px tall) at the slot top edge.
|
||||
- Inactive slot: `var(--color-text-muted)` icon + label.
|
||||
- Tap target: full slot — no inner padding gymnastics. iOS HIG ≥44pt;
|
||||
56px height + ~70px wide slot easily clears that.
|
||||
|
||||
### Center slot ([+])
|
||||
|
||||
- Visually elevated: a 48px circular lime button raised ~4px above the
|
||||
bar (negative margin-top), white plus-icon, subtle `box-shadow:
|
||||
var(--shadow-md)`.
|
||||
- Same width slot underneath; the circle is decoration, the whole slot
|
||||
is the tap target.
|
||||
- This is the only "loud" pattern; matches the baseline doc's
|
||||
"primary capture/add action" emphasis.
|
||||
|
||||
### Quick-Add sheet
|
||||
|
||||
- Width: 100vw on mobile, max 480px on tablet (the sheet should never
|
||||
appear on desktop because the [+] slot only exists on phones, but
|
||||
cap width as belt-and-braces).
|
||||
- Border-radius: `16px 16px 0 0` (top corners rounded, bottom flush).
|
||||
- Padding-bottom: `env(safe-area-inset-bottom)` so the cancel row sits
|
||||
above the home indicator.
|
||||
- Backdrop: `rgba(0,0,0,0.5)` via `<dialog>::backdrop`.
|
||||
|
||||
### Layout reflow
|
||||
|
||||
Pages with `body.has-sidebar` need extra bottom padding on mobile so
|
||||
the BottomNav doesn't cover the last row of content. New CSS rule:
|
||||
|
||||
```css
|
||||
@media (max-width: 767px) {
|
||||
body.has-sidebar main {
|
||||
padding-bottom: calc(var(--bottom-nav-height) + 1rem
|
||||
+ env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`main` gets the padding rather than `body` so the BottomNav's own
|
||||
fixed-position remains glued to the viewport edge.
|
||||
|
||||
### Keyboard-open hide
|
||||
|
||||
```css
|
||||
body.keyboard-open .bottom-nav {
|
||||
transform: translateY(120%);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
Toggle from JS via `visualViewport.height` delta > 100px (see §9).
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Add / Change
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `frontend/src/components/BottomNav.tsx` | TSX component, exports `BottomNav({currentPath, role?})` |
|
||||
| `frontend/src/client/bottom-nav.ts` | `initBottomNav()` — drawer toggle wiring, sheet open/close, visualViewport keyboard watcher |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `frontend/src/components/Sidebar.tsx` | Hamburger button gains a class so CSS can hide it `<768px` (`.sidebar-hamburger.hide-on-phone` or just by media query — no markup change needed). Add `id` on the toggle target so bottom-nav.ts can find/share it. |
|
||||
| `frontend/src/client/sidebar.ts` | Export `toggleMobileSidebar()` so bottom-nav.ts re-uses the exact same open/close/overlay code (don't duplicate). |
|
||||
| `frontend/src/client/index.ts` | Add `import { initBottomNav } from "./bottom-nav"; initBottomNav();` |
|
||||
| `frontend/src/styles/global.css` | Add ~120 lines: `--bottom-nav-height` token, `.bottom-nav` + slot styles, `<768px` media query showing BottomNav and hiding hamburger, keyboard-open transform, `body.has-sidebar main` padding-bottom rule, sheet styles. |
|
||||
| All page `*.tsx` files (~25) | (a) Replace `<meta name="viewport" content="width=device-width, initial-scale=1.0" />` with `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />`. (b) Add `<BottomNav currentPath="..." />` next to existing `<Sidebar currentPath="..." />` in each page. Easiest as a sed for (a); each page is touched once for (b). |
|
||||
| `frontend/build.ts` | Add `bottom-nav.ts` entry... — actually `bottom-nav.ts` is imported by `index.ts` so it gets bundled into `index.js` — no separate entry needed. |
|
||||
|
||||
### Optionally (low-cost, highly recommended)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| All page `*.tsx` `<head>` | Add `<meta name="theme-color" content="#65a30d" />` (lime, matches accent) so iOS Safari paints the URL bar lime in standalone mode. |
|
||||
| All page `*.tsx` `<head>` | Add `<meta name="apple-mobile-web-app-capable" content="yes" />` and `<meta name="apple-mobile-web-app-status-bar-style" content="default" />`. Cheap, no asset dependency. |
|
||||
|
||||
These three meta-tag rows are a one-time sed across 25 files; adding
|
||||
them now means we don't need a follow-up just to redo the sweep.
|
||||
|
||||
### NOT in this task
|
||||
|
||||
- `manifest.json` and icon assets (192/512 maskable PNGs) → follow-up.
|
||||
- Service worker / `sw.js` / app-shell caching → follow-up.
|
||||
- `beforeinstallprompt` add-to-home-screen UI → follow-up.
|
||||
|
||||
These are tracked under §11 below as proposed `t-paliad-04*` follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## 9. Behavior Spec (`bottom-nav.ts`)
|
||||
|
||||
```ts
|
||||
// Pseudo-shape (real impl will follow paliad style — no narration comments).
|
||||
import { toggleMobileSidebar } from "./sidebar";
|
||||
|
||||
export function initBottomNav() {
|
||||
initDrawerSlot(); // [Menü] tap → toggleMobileSidebar()
|
||||
initQuickAddSheet(); // [+] tap → dialog.showModal(); rows nav
|
||||
initKeyboardWatcher(); // visualViewport resize → body.keyboard-open
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard watcher (the one tricky bit)
|
||||
|
||||
```ts
|
||||
function initKeyboardWatcher() {
|
||||
if (!window.visualViewport) return; // older browsers: no-op
|
||||
const baseHeight = window.innerHeight;
|
||||
const KEYBOARD_THRESHOLD = 100; // px shrink == keyboard
|
||||
window.visualViewport.addEventListener("resize", () => {
|
||||
const delta = baseHeight - window.visualViewport!.height;
|
||||
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`baseHeight` is captured once at init — re-orientation events update it
|
||||
via a `window.orientationchange` handler. Edge case: a user who rotates
|
||||
the phone while the keyboard is open will see the bar reappear briefly
|
||||
until the keyboard re-deploys. Acceptable.
|
||||
|
||||
### Active-tab class on navigation
|
||||
|
||||
The TSX component renders the active class server-side from
|
||||
`currentPath`, identical to Sidebar. No client-side recomputation
|
||||
needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Z-index Map (post-change)
|
||||
|
||||
| Layer | z-index | Notes |
|
||||
|---|---|---|
|
||||
| Page content | auto | |
|
||||
| Header | 10 | Existing `.header` |
|
||||
| **BottomNav** | **30** | New |
|
||||
| Sidebar overlay (drawer backdrop) | 35 | Existing |
|
||||
| Sidebar drawer | 40 | Existing |
|
||||
| Top-left hamburger (legacy, tablet) | 50 | Existing — hidden <768px |
|
||||
| Quick-Add sheet backdrop | 90 | New (or just rely on `<dialog>::backdrop`) |
|
||||
| Quick-Add sheet card | 100 | New, same tier as `.modal-overlay` |
|
||||
| Existing modal-overlay (invite, etc.) | 100 | Existing |
|
||||
|
||||
When the drawer is open over the BottomNav: the drawer (z=40) is wider
|
||||
than the BottomNav (z=30), and its overlay (z=35) sits above the
|
||||
BottomNav — so the BottomNav is fully covered. ✓
|
||||
|
||||
When the Quick-Add sheet is open over the BottomNav: sheet (z=100)
|
||||
sits above; backdrop dims everything below including BottomNav. ✓
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollout Plan
|
||||
|
||||
**Single PR, single coherent commit per phase per task convention:**
|
||||
|
||||
1. *Phase 1 (this task, after m's go):* land BottomNav + Quick-Add sheet
|
||||
+ drawer wiring + viewport-fit meta + theme-color meta. One commit
|
||||
on this worktree's branch (`mai/cronus/pwa-mobile-bottom-nav`),
|
||||
self-merge to `main` per t-paliad-038/039/040 precedent.
|
||||
2. *Verify on mobile breakpoint:* Playwright (`browser_resize` to
|
||||
375×812 iPhone X) — confirm: BottomNav renders, sheet opens, drawer
|
||||
opens from `[Menü]`, no double-hamburger, content padding leaves the
|
||||
last item visible above the bar. Login as `tester@hlc.de` to test
|
||||
the authenticated paths.
|
||||
3. *Build green:* `bun run build` and `go build ./... && go vet ./... && go test ./...`.
|
||||
|
||||
### Follow-up tasks proposed (NOT in this task)
|
||||
|
||||
- `t-paliad-04X` — `manifest.json` + 192/512 maskable icons + `<link rel="apple-touch-icon">` on every page → installable PWA.
|
||||
- `t-paliad-04Y` — `sw.js` network-first cache app-shell strategy (copy from mBrian; keep tiny — just `/dashboard` and `/assets/global.css`).
|
||||
- `t-paliad-04Z` — `beforeinstallprompt` UI: a one-time toast ("Add Paliad to your Home Screen?") gated by a localStorage `paliad-pwa-prompt-dismissed` flag.
|
||||
- `t-paliad-04W` — Drag-to-dismiss for Quick-Add sheet (otto-pwa pattern verbatim).
|
||||
- `t-paliad-04V` — Project-detail tabs horizontal-overflow polish (already-known tablet/phone problem; surfaced again here but out of scope).
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions for m
|
||||
|
||||
1. **Slot 4: Agenda or Fristen?** Design picks Agenda. Brief offered
|
||||
either. If you prefer the more old-school Fristen (deadlines only,
|
||||
no Termine), it's a one-line swap. Recommendation: **Agenda**.
|
||||
2. **Center [+] slot: sheet or deep-link?** Design picks the 3-option
|
||||
slide-up sheet. If you prefer to skip the sheet and have [+] always
|
||||
go to `/deadlines/new` (the most-frequent capture), say so —
|
||||
simpler, no `<dialog>`. Recommendation: **sheet**.
|
||||
3. **PWA shell items:** Add the 3 meta tags now (viewport-fit,
|
||||
theme-color, apple-mobile-web-app-capable) but defer manifest +
|
||||
service worker + install prompt to follow-up tasks?
|
||||
Recommendation: **yes — meta now, manifest/SW/prompt later.**
|
||||
4. **`/projects/new` quick-add row visibility:** non-admins can't create
|
||||
projects. Hide the row for them, or always show and let the page
|
||||
gracefully error? Recommendation: **always show**, defer the
|
||||
permission-aware row to a follow-up — keeps this PR self-contained
|
||||
and matches what the Sidebar already does (`Projekte` is shown to
|
||||
everyone; admin-gating happens on the destination page).
|
||||
5. **Badge counts on BottomNav slots** (e.g. red-dot on Agenda when an
|
||||
overdue Frist is due today)? Nice-to-have, not in v1. Out of scope
|
||||
here. Confirm: **defer to follow-up.**
|
||||
6. **Tablet (768-1023px) behavior:** keep as-is (hamburger drawer, no
|
||||
BottomNav)? The pwa-baseline doc draws the line at 768 — we honor
|
||||
it on the BottomNav side. Confirm: **yes, no BottomNav on tablet.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance Mapping
|
||||
|
||||
| Requirement | How design satisfies |
|
||||
|---|---|
|
||||
| Design doc at `docs/design-pwa-bottom-nav.md` | This file |
|
||||
| BottomNav renders <768px, hidden ≥768px | §6 + media query in §8 |
|
||||
| Mobile drawer slides out, mirrors desktop Sidebar | §5 — reuses existing Sidebar.tsx + slide-out CSS |
|
||||
| Keyboard-open hides BottomNav | §9 visualViewport watcher + `body.keyboard-open` CSS |
|
||||
| safe-area-inset-bottom padding on iOS | §7 dimensions + §8 viewport-fit=cover meta |
|
||||
| No regression in desktop layout | Desktop ≥1024px untouched; only `<768px` adds BottomNav and hides hamburger; tablet 768-1023px unchanged |
|
||||
| Single commit per phase | §11 rollout |
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
|
||||
@@ -12,13 +13,17 @@ export function renderAgenda(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="agenda.title">Agenda — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/agenda" />
|
||||
<BottomNav currentPath="/agenda" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAppointmentsCalendar(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderAppointmentsCalendar(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="termine.kalender.title">Terminkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/appointments" />
|
||||
<BottomNav currentPath="/appointments" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAppointmentsDetail(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderAppointmentsDetail(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="termine.detail.title">Termin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/appointments" />
|
||||
<BottomNav currentPath="/appointments" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAppointmentsNew(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderAppointmentsNew(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="termine.neu.title">Neuer Termin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/appointments/new" />
|
||||
<BottomNav currentPath="/appointments/new" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAppointments(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderAppointments(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="termine.list.title">Termine — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/appointments" />
|
||||
<BottomNav currentPath="/appointments" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderChangelog(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderChangelog(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="changelog.title">Neuigkeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/changelog" />
|
||||
<BottomNav currentPath="/changelog" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Template detail page. Shows template metadata + list of existing
|
||||
@@ -11,12 +12,16 @@ export function renderChecklistsDetail(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="checklisten.title">Checkliste — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklists" />
|
||||
<BottomNav currentPath="/checklists" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Interactive instance page. Loads template + instance JSON, renders
|
||||
@@ -10,12 +11,16 @@ export function renderChecklistsInstance(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="checklisten.instance.title">Checklisten-Instanz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklists" />
|
||||
<BottomNav currentPath="/checklists" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderChecklists(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderChecklists(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="checklisten.title">Checklisten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklists" />
|
||||
<BottomNav currentPath="/checklists" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
115
frontend/src/client/bottom-nav.ts
Normal file
115
frontend/src/client/bottom-nav.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { toggleMobileSidebar } from "./sidebar";
|
||||
|
||||
const KEYBOARD_THRESHOLD_PX = 100;
|
||||
const BADGE_REFRESH_MS = 60_000;
|
||||
|
||||
export function initBottomNav(): void {
|
||||
const nav = document.getElementById("bottom-nav");
|
||||
if (!nav) return;
|
||||
|
||||
initMenuSlot();
|
||||
initQuickAddSheet();
|
||||
initKeyboardWatcher();
|
||||
initAgendaBadge();
|
||||
}
|
||||
|
||||
function initMenuSlot(): void {
|
||||
const btn = document.getElementById("bottom-nav-menu");
|
||||
btn?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
toggleMobileSidebar();
|
||||
});
|
||||
}
|
||||
|
||||
function initQuickAddSheet(): void {
|
||||
const trigger = document.getElementById("bottom-nav-add") as HTMLButtonElement | null;
|
||||
const dialog = document.getElementById("quick-add-sheet") as HTMLDialogElement | null;
|
||||
const cancel = document.getElementById("quick-add-cancel") as HTMLButtonElement | null;
|
||||
if (!trigger || !dialog) return;
|
||||
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (typeof dialog.showModal === "function") {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.setAttribute("open", "");
|
||||
}
|
||||
dialog.classList.add("is-open");
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
dialog!.classList.remove("is-open");
|
||||
if (typeof dialog!.close === "function") {
|
||||
dialog!.close();
|
||||
} else {
|
||||
dialog!.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
cancel?.addEventListener("click", close);
|
||||
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
dialog.classList.remove("is-open");
|
||||
});
|
||||
|
||||
dialog.querySelectorAll<HTMLAnchorElement>(".quick-add-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
// Native <a> navigation handles routing; close sheet first so it
|
||||
// does not flash on next page paint via bfcache.
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initKeyboardWatcher(): void {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
let baseHeight = window.innerHeight;
|
||||
window.addEventListener("orientationchange", () => {
|
||||
setTimeout(() => {
|
||||
baseHeight = window.innerHeight;
|
||||
document.body.classList.remove("keyboard-open");
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const handler = () => {
|
||||
const delta = baseHeight - vv.height;
|
||||
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD_PX);
|
||||
};
|
||||
vv.addEventListener("resize", handler);
|
||||
}
|
||||
|
||||
function initAgendaBadge(): void {
|
||||
const badge = document.getElementById("bottom-nav-agenda-badge");
|
||||
if (!badge) return;
|
||||
|
||||
function refresh(): void {
|
||||
fetch("/api/deadlines/summary", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { overdue?: number; today?: number } | null) => {
|
||||
if (!data) return;
|
||||
const overdue = typeof data.overdue === "number" ? data.overdue : 0;
|
||||
const today = typeof data.today === "number" ? data.today : 0;
|
||||
const total = overdue + today;
|
||||
if (total <= 0) {
|
||||
badge!.style.display = "none";
|
||||
badge!.classList.remove("bottom-nav-badge-overdue");
|
||||
return;
|
||||
}
|
||||
badge!.textContent = total > 9 ? "9+" : String(total);
|
||||
badge!.style.display = "";
|
||||
badge!.classList.toggle("bottom-nav-badge-overdue", overdue > 0);
|
||||
})
|
||||
.catch(() => {
|
||||
// Badge is decorative; never break the page.
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, BADGE_REFRESH_MS);
|
||||
}
|
||||
@@ -37,6 +37,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.neuigkeiten": "Neuigkeiten",
|
||||
"nav.soon.tooltip": "Bald verf\u00fcgbar",
|
||||
|
||||
// BottomNav (mobile)
|
||||
"bottomnav.add": "Anlegen",
|
||||
"bottomnav.menu": "Menü",
|
||||
"bottomnav.add.title": "Schnell anlegen",
|
||||
"bottomnav.add.deadline": "Frist anlegen",
|
||||
"bottomnav.add.deadline.sub": "Neue Frist mit Datum & Projekt",
|
||||
"bottomnav.add.appointment": "Termin anlegen",
|
||||
"bottomnav.add.appointment.sub": "Neuer Termin mit Uhrzeit & Ort",
|
||||
"bottomnav.add.project": "Projekt anlegen",
|
||||
"bottomnav.add.project.sub": "Neues Mandat / Verfahren / Patent",
|
||||
"bottomnav.add.cancel": "Abbrechen",
|
||||
|
||||
// Changelog (What's New) — t-paliad-027
|
||||
"changelog.title": "Neuigkeiten — Paliad",
|
||||
"changelog.heading": "Neuigkeiten",
|
||||
@@ -1157,6 +1169,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.neuigkeiten": "What's New",
|
||||
"nav.soon.tooltip": "Coming soon",
|
||||
|
||||
// BottomNav (mobile)
|
||||
"bottomnav.add": "New",
|
||||
"bottomnav.menu": "Menu",
|
||||
"bottomnav.add.title": "Quick add",
|
||||
"bottomnav.add.deadline": "New deadline",
|
||||
"bottomnav.add.deadline.sub": "Deadline with date & project",
|
||||
"bottomnav.add.appointment": "New appointment",
|
||||
"bottomnav.add.appointment.sub": "Appointment with time & place",
|
||||
"bottomnav.add.project": "New project",
|
||||
"bottomnav.add.project.sub": "New matter / case / patent",
|
||||
"bottomnav.add.cancel": "Cancel",
|
||||
|
||||
// Changelog (What's New) — t-paliad-027
|
||||
"changelog.title": "What's New — Paliad",
|
||||
"changelog.heading": "What's New",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initBottomNav } from "./bottom-nav";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initBottomNav();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,25 @@ import { getChangelogSeen } from "./changelog-seen";
|
||||
const PIN_KEY = "paliad-sidebar-pinned";
|
||||
const LEGACY_PIN_KEY = "patholo-sidebar-pinned";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
// machinery (overlay, body-scroll lock, etc.).
|
||||
export function toggleMobileSidebar(): void {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
const overlay = document.querySelector<HTMLElement>(".sidebar-overlay");
|
||||
if (!sidebar) return;
|
||||
const isOpen = sidebar.classList.contains("mobile-open");
|
||||
if (isOpen) {
|
||||
sidebar.classList.remove("mobile-open");
|
||||
overlay?.classList.remove("visible");
|
||||
document.body.classList.remove("no-scroll");
|
||||
} else {
|
||||
sidebar.classList.add("mobile-open");
|
||||
overlay?.classList.add("visible");
|
||||
document.body.classList.add("no-scroll");
|
||||
}
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -85,14 +104,7 @@ export function initSidebar() {
|
||||
|
||||
if (hamburger) {
|
||||
hamburger.addEventListener("click", () => {
|
||||
const isOpen = sidebar.classList.contains("mobile-open");
|
||||
if (isOpen) {
|
||||
closeMobile();
|
||||
} else {
|
||||
sidebar.classList.add("mobile-open");
|
||||
overlay?.classList.add("visible");
|
||||
document.body.classList.add("no-scroll");
|
||||
}
|
||||
toggleMobileSidebar();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +112,12 @@ export function initSidebar() {
|
||||
overlay.addEventListener("click", closeMobile);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && sidebar.classList.contains("mobile-open")) {
|
||||
closeMobile();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile sidebar on nav click
|
||||
sidebar.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
|
||||
80
frontend/src/components/BottomNav.tsx
Normal file
80
frontend/src/components/BottomNav.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { h, Fragment } from "../jsx";
|
||||
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
|
||||
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
||||
const ICON_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
|
||||
const ICON_DEADLINE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
const ICON_APPOINTMENT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
|
||||
interface BottomNavProps {
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
function isActive(href: string, currentPath: string): boolean {
|
||||
return href === currentPath || (href !== "/" && currentPath.startsWith(href + "/"));
|
||||
}
|
||||
|
||||
function slot(href: string, icon: string, i18nKey: string, label: string, currentPath: string, badgeId?: string): string {
|
||||
const active = isActive(href, currentPath);
|
||||
return (
|
||||
<a href={href} className={`bottom-nav-slot${active ? " active" : ""}`} data-bn-slot={href}>
|
||||
<span className="bottom-nav-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
<span className="bottom-nav-label" data-i18n={i18nKey}>{label}</span>
|
||||
{badgeId ? <span className="bottom-nav-badge" id={badgeId} style="display:none" aria-hidden="true" /> : ""}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomNav({ currentPath }: BottomNavProps): string {
|
||||
return (
|
||||
<Fragment>
|
||||
<nav className="bottom-nav" id="bottom-nav" aria-label="Mobile navigation">
|
||||
{slot("/dashboard", ICON_GAUGE, "nav.home", "Start", currentPath)}
|
||||
{slot("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath)}
|
||||
|
||||
<button type="button" className="bottom-nav-slot bottom-nav-add" id="bottom-nav-add" aria-label="Anlegen">
|
||||
<span className="bottom-nav-add-circle" dangerouslySetInnerHTML={{ __html: ICON_PLUS }} />
|
||||
<span className="bottom-nav-label" data-i18n="bottomnav.add">Anlegen</span>
|
||||
</button>
|
||||
|
||||
{slot("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath, "bottom-nav-agenda-badge")}
|
||||
|
||||
<button type="button" className="bottom-nav-slot" id="bottom-nav-menu" aria-label="Menü">
|
||||
<span className="bottom-nav-icon" dangerouslySetInnerHTML={{ __html: ICON_MENU }} />
|
||||
<span className="bottom-nav-label" data-i18n="bottomnav.menu">Menü</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog className="quick-add-sheet" id="quick-add-sheet" aria-label="Schnell anlegen">
|
||||
<div className="quick-add-card">
|
||||
<div className="quick-add-handle" aria-hidden="true" />
|
||||
<h2 className="quick-add-title" data-i18n="bottomnav.add.title">Schnell anlegen</h2>
|
||||
<a href="/deadlines/new" className="quick-add-row" data-bn-add="deadline">
|
||||
<span className="quick-add-icon" dangerouslySetInnerHTML={{ __html: ICON_DEADLINE }} />
|
||||
<span className="quick-add-row-label">
|
||||
<span className="quick-add-row-title" data-i18n="bottomnav.add.deadline">Frist anlegen</span>
|
||||
<span className="quick-add-row-sub" data-i18n="bottomnav.add.deadline.sub">Neue Frist mit Datum & Projekt</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href="/appointments/new" className="quick-add-row" data-bn-add="appointment">
|
||||
<span className="quick-add-icon" dangerouslySetInnerHTML={{ __html: ICON_APPOINTMENT }} />
|
||||
<span className="quick-add-row-label">
|
||||
<span className="quick-add-row-title" data-i18n="bottomnav.add.appointment">Termin anlegen</span>
|
||||
<span className="quick-add-row-sub" data-i18n="bottomnav.add.appointment.sub">Neuer Termin mit Uhrzeit & Ort</span>
|
||||
</span>
|
||||
</a>
|
||||
<a href="/projects/new" className="quick-add-row" data-bn-add="project">
|
||||
<span className="quick-add-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||
<span className="quick-add-row-label">
|
||||
<span className="quick-add-row-title" data-i18n="bottomnav.add.project">Projekt anlegen</span>
|
||||
<span className="quick-add-row-sub" data-i18n="bottomnav.add.project.sub">Neues Mandat / Verfahren / Patent</span>
|
||||
</span>
|
||||
</a>
|
||||
<button type="button" className="quick-add-cancel" id="quick-add-cancel" data-i18n="bottomnav.add.cancel">Abbrechen</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderCourts(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderCourts(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="gerichte.title">Gerichtsverzeichnis — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/courts" />
|
||||
<BottomNav currentPath="/courts" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
@@ -14,13 +15,17 @@ export function renderDashboard(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="dashboard.title">Dashboard — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/dashboard" />
|
||||
<BottomNav currentPath="/dashboard" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderDeadlinesCalendar(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderDeadlinesCalendar(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="fristen.kalender.title">Fristenkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/deadlines" />
|
||||
<BottomNav currentPath="/deadlines" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderDeadlinesDetail(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderDeadlinesDetail(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="fristen.detail.title">Frist — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/deadlines" />
|
||||
<BottomNav currentPath="/deadlines" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderDeadlinesNew(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderDeadlinesNew(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="fristen.neu.title">Neue Frist — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/deadlines/new" />
|
||||
<BottomNav currentPath="/deadlines/new" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderDeadlines(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderDeadlines(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="fristen.list.title">Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/deadlines" />
|
||||
<BottomNav currentPath="/deadlines" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
const ICON_WORD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13l1.5 5 1.5-4 1.5 4 1.5-5"/></svg>';
|
||||
@@ -29,12 +30,16 @@ export function renderDownloads(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="downloads.title">Downloads — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/downloads" />
|
||||
<BottomNav currentPath="/downloads" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
interface ProceedingDef {
|
||||
@@ -41,12 +42,16 @@ export function renderFristenrechner(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="fristen.title">Fristenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/fristenrechner" />
|
||||
<BottomNav currentPath="/tools/fristenrechner" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderGebuehrentabellen(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderGebuehrentabellen(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="gebuehren.title">Gebührentabellen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/gebuehrentabellen" />
|
||||
<BottomNav currentPath="/tools/gebuehrentabellen" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderGlossary(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderGlossary(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="glossar.title">Patentglossar — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/glossary" />
|
||||
<BottomNav currentPath="/glossary" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -18,7 +18,10 @@ export function renderIndex(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="index.title">Paliad — Patentwissen für Hogan Lovells</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="8" y2="10.01"/><line x1="12" y1="10" x2="12" y2="10.01"/><line x1="16" y1="10" x2="16" y2="10.01"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
|
||||
@@ -92,12 +93,16 @@ export function renderKostenrechner(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="kosten.title">Prozesskostenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/kostenrechner" />
|
||||
<BottomNav currentPath="/tools/kostenrechner" />
|
||||
|
||||
<div className="print-header" id="print-header">
|
||||
<div className="print-header-brand">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderLinks(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderLinks(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="links.title">Links — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/links" />
|
||||
<BottomNav currentPath="/links" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -7,7 +7,10 @@ export function renderLogin(loginJs: string): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="login.title">Anmelden — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// renderNotFound is the chromed 404 page served for any unknown
|
||||
@@ -11,12 +12,16 @@ export function renderNotFound(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="notfound.title">Seite nicht gefunden — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="" />
|
||||
<BottomNav currentPath="" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -7,7 +7,10 @@ export function renderOnboarding(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="onboarding.title">Willkommen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Project detail shell (v2). DOM IDs use the English `project-*` /
|
||||
@@ -11,12 +12,16 @@ export function renderProjectsDetail(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="projekte.detail.title">Projekt — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// "Neues Projekt" form (v2). Rendered at /projekte/neu. Supports five types;
|
||||
@@ -9,12 +10,16 @@ export function renderProjectsNew(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="projekte.neu.title">Neues Projekt — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects/new" />
|
||||
<BottomNav currentPath="/projects/new" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Renders the /projekte list page. File + export name stays `Akten` for build
|
||||
@@ -9,12 +10,16 @@ export function renderProjects(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="projekte.title">Projekte — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Unified settings page. Three tabs today (Profil / Benachrichtigungen / CalDAV)
|
||||
@@ -11,12 +12,16 @@ export function renderSettings(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="einstellungen.title">Einstellungen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/settings" />
|
||||
<BottomNav currentPath="/settings" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
--max-width: 1080px;
|
||||
--sidebar-collapsed: 64px;
|
||||
--sidebar-expanded: 240px;
|
||||
--bottom-nav-height: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
@@ -6304,3 +6305,281 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- BottomNav (mobile, <768px) --- */
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.04);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
|
||||
.bottom-nav-slot {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
height: var(--bottom-nav-height);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.1rem 0.35rem;
|
||||
position: relative;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.bottom-nav-slot.active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.bottom-nav-slot.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 3px;
|
||||
background: var(--color-accent);
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom-nav-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
line-height: 1;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.bottom-nav-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: calc(50% - 18px);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--color-surface);
|
||||
}
|
||||
|
||||
.bottom-nav-badge-overdue {
|
||||
background: #dc2626;
|
||||
animation: bn-pulse 1800ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bn-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 2px var(--color-surface); }
|
||||
50% { box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 5px rgba(220, 38, 38, 0.25); }
|
||||
}
|
||||
|
||||
/* Center [+] slot — visually elevated lime circle */
|
||||
.bottom-nav-add {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bottom-nav-add-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
margin-top: -10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.bottom-nav-add:active .bottom-nav-add-circle {
|
||||
background: var(--color-accent-light);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.bottom-nav-add-circle svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Hide BottomNav when keyboard is open (visualViewport watcher) */
|
||||
body.keyboard-open .bottom-nav {
|
||||
transform: translateY(120%);
|
||||
}
|
||||
|
||||
/* --- Quick-Add slide-up sheet --- */
|
||||
|
||||
dialog.quick-add-sheet {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
dialog.quick-add-sheet[open] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
dialog.quick-add-sheet::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.quick-add-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transform: translateY(100%);
|
||||
transition: transform 220ms ease-out;
|
||||
}
|
||||
|
||||
.quick-add-sheet.is-open .quick-add-card {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.quick-add-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-border);
|
||||
margin: 0.5rem auto 0.75rem;
|
||||
}
|
||||
|
||||
.quick-add-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
.quick-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.quick-add-row:hover,
|
||||
.quick-add-row:active {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.quick-add-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(101, 163, 13, 0.1);
|
||||
color: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-add-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.quick-add-row-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-add-row-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quick-add-row-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.quick-add-cancel {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-add-cancel:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* --- Phone breakpoint (<768px): show BottomNav, hide legacy hamburger --- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-hamburger {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.has-sidebar main {
|
||||
padding-bottom: calc(var(--bottom-nav-height) + 1rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderTeam(): string {
|
||||
@@ -7,12 +8,16 @@ export function renderTeam(): string {
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title data-i18n="team.title">Team — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/team" />
|
||||
<BottomNav currentPath="/team" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
|
||||
@@ -385,6 +385,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID)
|
||||
// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
|
||||
type SummaryCounts struct {
|
||||
Overdue int `json:"overdue" db:"overdue"`
|
||||
Today int `json:"today" db:"today"`
|
||||
ThisWeek int `json:"this_week" db:"this_week"`
|
||||
Upcoming int `json:"upcoming" db:"upcoming"`
|
||||
Completed int `json:"completed" db:"completed"`
|
||||
@@ -403,14 +404,16 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := now.Truncate(24 * time.Hour)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
endWeek := today.AddDate(0, 0, 7)
|
||||
|
||||
conds := []string{visibilityPredicate("p")}
|
||||
args := map[string]any{
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"today": today,
|
||||
"endweek": endWeek,
|
||||
"user_id": userID,
|
||||
"role": user.Role,
|
||||
"today": today,
|
||||
"tomorrow": tomorrow,
|
||||
"endweek": endWeek,
|
||||
}
|
||||
if projektID != nil {
|
||||
conds = append(conds, `f.project_id = :project_id`)
|
||||
@@ -420,6 +423,7 @@ func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, p
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
|
||||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :tomorrow) AS today,
|
||||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek) AS this_week,
|
||||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
|
||||
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
|
||||
|
||||
Reference in New Issue
Block a user