diff --git a/assets/css/app.css b/assets/css/app.css index c061c4b..3c5837c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -189,6 +189,7 @@ html { min-height: 100%; min-height: -webkit-fill-available; color-scheme: dark; + overflow-x: hidden; } body { @@ -201,6 +202,7 @@ body { var(--body-radial-two), var(--body-gradient); color: var(--text); + overflow-x: hidden; } body.page-dashboard { @@ -211,6 +213,15 @@ body.page-dashboard { var(--body-gradient); } +body.page-dashboard, +body.page-dashboard .shell--dashboard, +body.page-dashboard .content, +body.page-dashboard .dashboard-shell { + width: 100%; + max-width: none; + margin: 0; +} + body.is-dashboard-overlay-open { overflow: hidden; } @@ -304,6 +315,7 @@ button:disabled { .shell--dashboard { display: block; padding: 0; + overflow: hidden; } .sidebar, @@ -339,11 +351,15 @@ button:disabled { body.page-dashboard .content { min-height: 100vh; padding: 0; + gap: 0; } .dashboard-shell { position: relative; + width: 100%; + max-width: none; min-height: 100vh; + min-height: 100dvh; padding: max(1.1rem, env(safe-area-inset-top)) 1rem calc(4rem + env(safe-area-inset-bottom)); background: linear-gradient(180deg, rgba(7, 18, 34, 0.12), rgba(7, 18, 34, 0.42)), @@ -353,8 +369,12 @@ body.page-dashboard .content { } .dashboard-shell__background { - position: absolute; + position: fixed; inset: 0; + width: 100%; + height: 100vh; + height: 100dvh; + transform: none; z-index: 0; } @@ -424,8 +444,10 @@ body.page-dashboard .content { padding: 0; width: 3.6rem; height: 3.6rem; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.08); + border-color: var(--surface-border); + background: + var(--panel-gradient-top), + var(--panel-gradient-accent); } .dashboard-settings img { @@ -646,7 +668,7 @@ body.page-dashboard .content { display: flex; flex-direction: column; gap: 0.9rem; - padding-bottom: 8rem; + padding-bottom: 5rem; } .dashboard-moments-block { @@ -654,9 +676,36 @@ body.page-dashboard .content { padding-left: clamp(0.7rem, 2vw, 1.35rem); } +.section-head--dashboard { + display: inline-flex; + width: fit-content; + max-width: 100%; + margin: 0 auto 0.8rem; + padding: 0.8rem 1rem; + border-radius: 1.35rem; + background: + linear-gradient(180deg, rgba(7, 18, 30, 0.58), rgba(7, 18, 30, 0.34)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.12), transparent 48%); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.56); + text-align: center; + justify-content: center; +} + +.section-head--dashboard > div { + width: 100%; +} + +.section-head--dashboard .eyebrow { + color: rgba(255, 255, 255, 0.82); +} + .section-head--dashboard h2 { margin: 0; font-size: 1.45rem; + color: rgba(255, 255, 255, 0.96); } .timeline-card { @@ -667,6 +716,19 @@ body.page-dashboard .content { border-radius: 1.65rem; } +.timeline-card__image { + grid-column: 1 / -1; + width: 100%; + max-height: 18rem; + object-fit: cover; + border-radius: 1.25rem; + margin-bottom: 0.2rem; +} + +.timeline-card__time-chip { + display: none; +} + .timeline-card--empty { display: block; } @@ -763,6 +825,13 @@ body.page-dashboard .content { border: 1px solid rgba(255, 255, 255, 0.08); } +.signal-pill__icon { + display: block; + width: 1rem; + height: 1rem; + filter: brightness(0) invert(1) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55)); +} + .signal-pill--good { background: rgba(144, 214, 108, 0.2); border-color: rgba(180, 255, 120, 0.34); @@ -778,6 +847,36 @@ body.page-dashboard .content { border-color: rgba(255, 209, 94, 0.28); } +.signal-pill--neg2 { + background: rgba(185, 47, 52, 0.72); + border-color: rgba(255, 150, 150, 0.72); + color: #fff; +} + +.signal-pill--neg1 { + background: rgba(189, 103, 36, 0.72); + border-color: rgba(255, 188, 130, 0.72); + color: #fff; +} + +.signal-pill--zero { + background: rgba(39, 128, 164, 0.7); + border-color: rgba(179, 238, 255, 0.72); + color: #fff; +} + +.signal-pill--pos1 { + background: rgba(35, 139, 105, 0.72); + border-color: rgba(180, 255, 224, 0.72); + color: #fff; +} + +.signal-pill--pos2 { + background: rgba(28, 151, 93, 0.76); + border-color: rgba(180, 255, 220, 0.78); + color: #fff; +} + .signal-dot { width: 0.85rem; height: 0.85rem; @@ -803,6 +902,52 @@ body.page-dashboard .content { backdrop-filter: blur(24px) saturate(180%); box-shadow: 0 16px 44px rgba(8, 18, 34, 0.35); font-size: 2rem; + transition: transform 180ms ease, background 180ms ease; +} + +.dashboard-fab.is-open { + transform: rotate(45deg) scale(0.96); +} + +.dashboard-fab-menu[hidden] { + display: none; +} + +.dashboard-fab-menu { + position: fixed; + right: max(1rem, env(safe-area-inset-right)); + bottom: calc(5.8rem + env(safe-area-inset-bottom)); + z-index: 28; + display: grid; + gap: 0.55rem; + width: min(18rem, calc(100vw - 2rem)); + padding: 0.7rem; + border-radius: 1.6rem; + animation: fabMenuIn 180ms ease both; + transform-origin: right bottom; +} + +.dashboard-fab-menu button { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.85rem 0.9rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1.1rem; + background: rgba(255, 255, 255, 0.07); + color: var(--text); + text-align: left; +} + +.dashboard-fab-menu img { + width: 1.35rem; + height: 1.35rem; +} + +@keyframes fabMenuIn { + from { opacity: 0; transform: translateY(0.6rem) scale(0.94); } + to { opacity: 1; transform: translateY(0) scale(1); } } .dashboard-composer { @@ -1080,7 +1225,7 @@ body.page-dashboard .content { } .dashboard-range-view { - padding-bottom: 3rem; + padding-bottom: 1.5rem; } .range-period-rail { @@ -1089,10 +1234,12 @@ body.page-dashboard .content { grid-auto-columns: minmax(42%, 42%); gap: 1rem; margin-inline: calc(clamp(0rem, (100vw - 920px) / -2, 0rem)); + padding: 0.35rem 0.7rem 1rem; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-width: none; + scroll-padding-inline: 0.7rem; } .range-period-rail::-webkit-scrollbar { @@ -1102,17 +1249,17 @@ body.page-dashboard .content { .range-period-panel { min-width: 0; scroll-snap-align: start; - padding: 0.25rem; - border-radius: 1.9rem; + padding: 0.6rem; + border-radius: 2.15rem; } .range-period-panel.is-selected { - background: rgba(139, 228, 255, 0.08); - box-shadow: 0 0 0 1px rgba(139, 228, 255, 0.28); + background: rgba(139, 228, 255, 0.1); + box-shadow: inset 0 0 0 1px rgba(139, 228, 255, 0.34), 0 18px 48px rgba(0, 0, 0, 0.16); } .range-period-panel__head { - padding: 0 0.25rem 0.7rem; + padding: 0.15rem 0.25rem 0.85rem; } .range-period-panel__head a { @@ -1142,9 +1289,9 @@ body.page-dashboard .content { display: grid; gap: 0.55rem; align-items: stretch; - margin-bottom: 1.1rem; - padding: 0.85rem; - border-radius: 1.7rem; + margin-bottom: 0.15rem; + padding: 1rem; + border-radius: 1.85rem; } .range-score-strip--week { @@ -1155,7 +1302,7 @@ body.page-dashboard .content { display: flex; gap: 0.18rem; overflow: visible; - padding-inline: 0.55rem; + padding-inline: 0.8rem; } .range-score-day { @@ -1546,10 +1693,12 @@ body.page-dashboard .content { display: flex; flex-direction: column; gap: 1rem; + min-height: 100vh; + min-height: 100dvh; } .options-menu-panel { - padding: 1.2rem; + padding: 0; border-radius: var(--radius-xl); } @@ -1571,12 +1720,15 @@ body.page-dashboard .content { gap: 0.35rem; align-items: flex-start; width: 100%; - padding: 1rem 1.1rem; - border-radius: 1.4rem; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.06); + padding: 1.15rem 1.25rem; + border-radius: 1.55rem; + border: 1px solid rgba(152, 194, 232, 0.16); + background: + linear-gradient(180deg, rgba(41, 59, 80, 0.72), rgba(25, 42, 63, 0.6)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 48%); color: var(--text); text-decoration: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } .options-menu-card strong { @@ -1621,7 +1773,7 @@ body.page-dashboard .content { .options-overlay__backdrop { position: absolute; inset: 0; - background: rgba(3, 9, 17, 0.64); + background: rgba(3, 9, 17, 0.78); backdrop-filter: blur(10px); } @@ -1639,6 +1791,9 @@ body.page-dashboard .content { calc(max(1.25rem, env(safe-area-inset-bottom)) + 1.75rem) max(1rem, env(safe-area-inset-left)); border-radius: 0; + background: + linear-gradient(180deg, rgba(8, 16, 28, 0.94), rgba(11, 31, 51, 0.9)), + radial-gradient(circle at 50% 0%, rgba(139, 228, 255, 0.12), transparent 42%); overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; @@ -1663,6 +1818,34 @@ body.page-dashboard .content { font-size: 2rem; } +.options-modal .settings-section, +.options-modal .band-card, +.options-modal .sport-type-card, +.options-modal .checkbox-row--panel, +.options-modal .push-panel, +.options-modal .detail-card--overlay { + border: 1px solid rgba(152, 194, 232, 0.14); + background: + linear-gradient(180deg, rgba(42, 62, 84, 0.56), rgba(23, 42, 62, 0.48)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 46%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.options-modal .settings-section { + padding: 1rem; + border-radius: 1.5rem; +} + +.options-modal input[type="text"], +.options-modal input[type="password"], +.options-modal input[type="number"], +.options-modal input[type="date"], +.options-modal select, +.options-modal textarea { + background: rgba(9, 22, 36, 0.62); + border-color: rgba(152, 194, 232, 0.18); +} + .detail-card--overlay { padding: 1rem; border-radius: 1.4rem; @@ -1690,25 +1873,180 @@ body.page-dashboard .content { } @media (max-width: 760px) { + body.page-dashboard .content, + .dashboard-shell { + width: 100%; + max-width: none; + overflow-x: hidden; + } + + .dashboard-shell { + padding-inline: 0; + padding-top: max(0.35rem, env(safe-area-inset-top)); + padding-bottom: calc(0.9rem + env(safe-area-inset-bottom)); + } + + .dashboard-shell__background { + inset: 0; + width: 100%; + transform: none; + } + + .dashboard-day, + .dashboard-range-view { + width: 100%; + padding: 0.6rem 0.75rem 0; + } + .range-period-rail { grid-auto-columns: minmax(86%, 86%); + margin-inline: -0.75rem; + padding-inline: 0.75rem; + } + + .range-period-panel { + padding: 0.55rem; + } + + .range-score-strip { + padding: 0.95rem; } .dashboard-topbar { - gap: 0.7rem; - align-items: start; + position: relative; + top: auto; + left: auto; + transform: none; + gap: 0.55rem; + align-items: center; width: calc(100% - 1rem); + margin: 0 auto 0.65rem; } .dashboard-switcher { width: auto; flex: 1; + padding: 0.18rem; } .dashboard-switcher a { min-width: 0; flex: 1; padding-inline: 0.8rem; + min-height: 3rem; + } + + .dashboard-settings { + flex: 0 0 3.36rem; + width: 3.36rem; + height: 3.36rem; + min-width: 3.36rem; + min-height: 3.36rem; + margin-left: 0; + } + + .timeline-card { + position: relative; + padding-top: 3.65rem; + overflow: hidden; + } + + .timeline-card__image { + width: 100%; + max-width: none; + margin: -2.65rem 0 0.7rem; + border-radius: 1.25rem; + } + + .timeline-card__time-chip { + position: absolute; + top: 0.75rem; + left: 8.75rem; + display: inline-flex; + align-items: center; + min-height: 2.25rem; + padding: 0 0.75rem; + border-radius: 999px; + background: rgba(8, 18, 30, 0.38); + border: 1px solid rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.82); + font-size: 0.86rem; + } + + .timeline-card__delete { + position: absolute; + top: 0.75rem; + right: 0.75rem; + } + + .timeline-card--with-image .timeline-card__time-chip, + .timeline-card--with-image .timeline-card__delete { + top: 2.05rem; + } + + .timeline-card--with-image .timeline-card__time-chip { + left: auto; + right: 5.15rem; + background: rgba(255, 255, 255, 0.86); + border-color: rgba(255, 255, 255, 0.62); + color: rgba(10, 22, 35, 0.92); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.2); + } + + .timeline-card--with-image .timeline-card__delete { + right: 1.6rem; + } + + .timeline-card__delete .ghost-button { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(255, 255, 255, 0.6); + color: rgba(10, 22, 35, 0.92); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); + } + + .timeline-card__meta { + display: none; + } + + .timeline-card .signal-row { + position: absolute; + top: 0.75rem; + left: 0.75rem; + margin: 0; + gap: 0.35rem; + } + + .timeline-card--with-image .signal-row { + top: 2.05rem; + left: 2.05rem; + } + + .timeline-card .signal-pill { + width: 2.38rem; + height: 2.38rem; + padding: 0; + justify-content: center; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.22); + } + + .timeline-card .signal-pill strong, + .timeline-card .signal-pill span { + display: none; + } + + .timeline-card .signal-pill__icon { + display: block; + width: 1.08rem; + height: 1.08rem; + filter: brightness(0) invert(1) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.7)); + } + + .dashboard-moments-block { + padding-left: 0; + } + + .timeline-list { + padding-bottom: calc(1.25rem + env(safe-area-inset-bottom)); } .day-summary-card__head, @@ -1729,6 +2067,7 @@ body.page-dashboard .content { border-radius: 0; padding-top: calc(max(1.25rem, env(safe-area-inset-top)) + 0.75rem); padding-bottom: calc(max(1.25rem, env(safe-area-inset-bottom)) + 1.75rem); + padding-inline: max(0.85rem, env(safe-area-inset-left)) max(0.85rem, env(safe-area-inset-right)); } .dashboard-overlay, @@ -1740,6 +2079,19 @@ body.page-dashboard .content { grid-template-columns: 1fr; } + .dashboard-modal__controls, + .options-modal__controls { + margin-inline: -0.2rem; + padding: 0.15rem 0 0.55rem; + background: linear-gradient(180deg, rgba(26, 26, 29, 0.96), rgba(26, 26, 29, 0.72), transparent); + } + + .dashboard-modal__round { + width: 3.4rem; + height: 3.4rem; + font-size: 1.65rem; + } + .overlay-signal-card { grid-template-columns: 1fr; } @@ -1758,8 +2110,9 @@ body.page-dashboard .content { max-height: 100%; min-height: 0; border-radius: 0; - padding-top: calc(max(1.25rem, env(safe-area-inset-top)) + 0.75rem); + padding-top: calc(max(1rem, env(safe-area-inset-top)) + 0.25rem); padding-bottom: calc(max(1.25rem, env(safe-area-inset-bottom)) + 1.75rem); + padding-inline: max(0.85rem, env(safe-area-inset-left)) max(0.85rem, env(safe-area-inset-right)); } } @@ -3507,6 +3860,11 @@ input[type="range"] { padding-bottom: calc(6.8rem + env(safe-area-inset-bottom)); } + body.page-dashboard.is-authenticated .content, + body.page-options.is-authenticated .content { + padding-bottom: 0; + } + .site-footer { margin-bottom: 0.5rem; } @@ -3620,6 +3978,12 @@ input[type="range"] { gap: 0.8rem; } + body.page-dashboard .shell, + body.page-options .shell { + padding: 0; + gap: 0; + } + .sidebar, .hero-card, .metric-card, diff --git a/assets/icons/signal-energy.svg b/assets/icons/signal-energy.svg new file mode 100644 index 0000000..67786ce --- /dev/null +++ b/assets/icons/signal-energy.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/signal-mood.svg b/assets/icons/signal-mood.svg new file mode 100644 index 0000000..73fc3bc --- /dev/null +++ b/assets/icons/signal-mood.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/signal-stress.svg b/assets/icons/signal-stress.svg new file mode 100644 index 0000000..9d18981 --- /dev/null +++ b/assets/icons/signal-stress.svg @@ -0,0 +1 @@ + diff --git a/assets/js/app.js b/assets/js/app.js index caf9fd1..69235ff 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -984,6 +984,7 @@ const openSettingsMenu = document.querySelector("[data-settings-menu-open]"); const closeSettingsMenu = [...document.querySelectorAll("[data-settings-menu-close]")]; const openMoment = document.querySelector("[data-moment-overlay-open]"); + const fabMenu = document.querySelector("[data-fab-menu]"); const closeMoment = [...document.querySelectorAll("[data-moment-overlay-close]")]; const chooseStep = document.querySelector('[data-moment-step="choose"]'); const formStep = document.querySelector('[data-moment-step="form"]'); @@ -1046,7 +1047,7 @@ } const stepperConfigs = { - event: { label: "Ereignis", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" }, + event: { label: "Moment", valueLabel: "Wert", unit: "", placeholder: "optional", showValue: false, showSport: false, showAlcohol: false, commentPlaceholder: "Was hast du erlebt?" }, walk: { label: "Spaziergang", valueLabel: "Spaziergang", unit: walkUnit, placeholder: walkMode === "steps" ? "Schritte" : "Minuten", showValue: true, showSport: false, showAlcohol: false, showWalk: true, commentPlaceholder: "Was war dabei besonders?" }, sport: { label: "Sport", valueLabel: "Dauer", unit: "min", placeholder: "Minuten", showValue: true, showSport: true, showAlcohol: false, commentPlaceholder: "Was hast du gemacht?" }, sleep: { label: "Schlaf", valueLabel: "Dauer", unit: "h", placeholder: "Stunden", showValue: true, showSport: false, showAlcohol: false, commentPlaceholder: "Wie war der Schlaf?" }, @@ -1090,7 +1091,7 @@ document.body.classList.toggle("is-dashboard-overlay-open", open); if (open) { - const focusTarget = overlay.querySelector("input, textarea, select, button"); + const focusTarget = overlay.querySelector("button, [href]"); if (focusTarget instanceof HTMLElement) { window.setTimeout(() => focusTarget.focus(), 10); } @@ -1302,11 +1303,47 @@ if (openMoment) { openMoment.addEventListener("click", event => { event.preventDefault(); + if (fabMenu instanceof HTMLElement) { + fabMenu.hidden = !fabMenu.hidden; + openMoment.classList.toggle("is-open", !fabMenu.hidden); + return; + } + showMomentChoose(); setOverlay(momentOverlay, true); }); } + document.querySelectorAll("[data-fab-moment-choice]").forEach(button => { + button.addEventListener("click", event => { + event.preventDefault(); + const type = button.dataset.fabMomentChoice || "event"; + if (fabMenu instanceof HTMLElement) { + fabMenu.hidden = true; + } + if (openMoment) { + openMoment.classList.remove("is-open"); + } + showMomentForm(type); + setOverlay(momentOverlay, true); + }); + }); + + document.addEventListener("click", event => { + if (!(fabMenu instanceof HTMLElement) || fabMenu.hidden) { + return; + } + + if (event.target.closest("[data-fab-menu]") || event.target.closest("[data-moment-overlay-open]")) { + return; + } + + fabMenu.hidden = true; + if (openMoment) { + openMoment.classList.remove("is-open"); + } + }); + closeMoment.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); @@ -1456,13 +1493,23 @@ } const panels = [...overlay.querySelectorAll("[data-options-panel]")]; + const menu = overlay.querySelector("[data-options-menu]"); const closeButtons = [...overlay.querySelectorAll("[data-options-close]")]; const backButtons = [...overlay.querySelectorAll("[data-options-back]")]; + const isStandalone = overlay.dataset.optionsStandalone === "1"; const initialPanel = overlay.dataset.openPanel || null; const setOpen = (panelName) => { - overlay.hidden = panelName === null; - document.body.classList.toggle("is-dashboard-overlay-open", panelName !== null); + overlay.hidden = !isStandalone && panelName === null; + document.body.classList.toggle("is-dashboard-overlay-open", isStandalone || panelName !== null); + + if (menu instanceof HTMLElement) { + menu.hidden = panelName !== null; + } + + backButtons.forEach(button => { + button.hidden = panelName === null; + }); panels.forEach(panel => { panel.hidden = panel.dataset.optionsPanel !== panelName; @@ -1485,6 +1532,10 @@ closeButtons.forEach(button => { button.addEventListener("click", event => { event.preventDefault(); + if (isStandalone) { + window.location.href = "/"; + return; + } setOpen(null); }); }); @@ -1493,11 +1544,16 @@ button.addEventListener("click", event => { event.preventDefault(); setOpen(null); + if (window.location.search.includes("panel=")) { + window.history.replaceState(null, "", "/options"); + } }); }); if (initialPanel) { setOpen(initialPanel); + } else if (isStandalone) { + setOpen(null); } } diff --git a/src/App.php b/src/App.php index bc27caa..cd197dc 100644 --- a/src/App.php +++ b/src/App.php @@ -94,6 +94,10 @@ final class App $this->serveDayImage(); return; + case '/event-image': + $this->serveEventImage(); + return; + case '/track': $method === 'POST' ? $this->handleTrack() : $this->showTrack(); return; @@ -327,8 +331,8 @@ final class App $upload = uploaded_files('background_image')[0] ?? null; if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { - $this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? '')); - $current['background_image'] = $this->storeDashboardBackgroundImage($user['username'], $date, $upload); + $this->deleteDashboardImage($user['username'], (string) ($current['background_image'] ?? '')); + $current['background_image'] = $this->storeDashboardImage($user['username'], $date, $upload); } $entryMap[$date] = $current; @@ -339,12 +343,16 @@ final class App if ($form === 'add_event') { $event = $this->dashboardEventFromPost($_POST); + $upload = uploaded_files('event_image')[0] ?? null; + if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $event['image'] = $this->storeDashboardImage($user['username'], $date, $upload); + } $events = is_array($current['events'] ?? null) ? $current['events'] : []; $events[] = $event; $current['events'] = $events; $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); - flash('success', 'Die Aktivität wurde hinzugefügt.'); + flash('success', 'Der Moment wurde hinzugefügt.'); redirect('/?view=day&date=' . rawurlencode($date)); } @@ -352,6 +360,7 @@ final class App $eventID = trim((string) ($_POST['event_id'] ?? '')); $updatedEvent = $this->dashboardEventFromPost($_POST); $updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id']; + $upload = uploaded_files('event_image')[0] ?? null; $events = []; foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) { @@ -360,6 +369,11 @@ final class App } if ((string) ($event['id'] ?? '') === $eventID) { + $updatedEvent['image'] = (string) ($event['image'] ?? ''); + if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? '')); + $updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload); + } $events[] = $updatedEvent; continue; } @@ -376,18 +390,23 @@ final class App if ($form === 'delete_event') { $eventID = trim((string) ($_POST['event_id'] ?? '')); + foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) { + if (is_array($event) && (string) ($event['id'] ?? '') === $eventID) { + $this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? '')); + } + } $current['events'] = array_values(array_filter( is_array($current['events'] ?? null) ? $current['events'] : [], static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID )); $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); - flash('success', 'Die Aktivität wurde entfernt.'); + flash('success', 'Der Moment wurde entfernt.'); redirect('/?view=day&date=' . rawurlencode($date)); } if ($form === 'remove_background') { - $this->deleteDashboardBackgroundImage($user['username'], $date, (string) ($current['background_image'] ?? '')); + $this->deleteDashboardImage($user['username'], (string) ($current['background_image'] ?? '')); $current['background_image'] = ''; $entryMap[$date] = $current; $this->persistUserEntries($user['username'], $settings, array_values($entryMap)); @@ -449,6 +468,8 @@ final class App 'unit' => (string) ($event['unit'] ?? ''), 'sport_type_id' => (string) ($event['sport_type_id'] ?? ''), 'consumed' => !empty($event['consumed']), + 'image' => (string) ($event['image'] ?? ''), + 'image_url' => is_string($event['image_url'] ?? null) ? (string) $event['image_url'] : null, 'mood' => normalize_signal_value($event['mood'] ?? 0), 'energy' => normalize_signal_value($event['energy'] ?? 0), 'stress' => normalize_signal_value($event['stress'] ?? 0), @@ -684,7 +705,7 @@ final class App $value = max(0, min(50000, (float) ($input['event_value'] ?? 0))); if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) { - throw new RuntimeException('Für diese Aktivität braucht es einen Wert oder eine Dauer.'); + throw new RuntimeException('Für diesen Moment braucht es einen Wert oder eine Dauer.'); } $sportTypeID = trim((string) ($input['event_sport_type_id'] ?? '')); @@ -724,6 +745,21 @@ final class App $date = (string) ($entry['date'] ?? ''); $entry['background_image_url'] = null; + $events = []; + foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { + if (!is_array($event)) { + continue; + } + + $event['image_url'] = null; + $eventImage = trim((string) ($event['image'] ?? '')); + if ($eventImage !== '' && is_file($this->dashboardMediaDirectory($username) . '/' . basename($eventImage))) { + $event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? '')); + } + $events[] = $event; + } + $entry['events'] = $events; + if ($fileName === '' || !$this->isValidDate($date)) { return $entry; } @@ -736,11 +772,11 @@ final class App return $entry; } - private function storeDashboardBackgroundImage(string $username, string $date, array $upload): string + private function storeDashboardImage(string $username, string $date, array $upload): string { $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); if ($error !== UPLOAD_ERR_OK) { - throw new RuntimeException('Das Tagesbild konnte nicht hochgeladen werden.'); + throw new RuntimeException('Das Bild konnte nicht hochgeladen werden.'); } $tmpName = (string) ($upload['tmp_name'] ?? ''); @@ -757,7 +793,7 @@ final class App }; if ($extension === '') { - throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Tagesbild.'); + throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Bild.'); } $directory = $this->dashboardMediaDirectory($username); @@ -769,13 +805,13 @@ final class App $target = $directory . '/' . $fileName; if (!move_uploaded_file($tmpName, $target)) { - throw new RuntimeException('Das Tagesbild konnte nicht gespeichert werden.'); + throw new RuntimeException('Das Bild konnte nicht gespeichert werden.'); } return $fileName; } - private function deleteDashboardBackgroundImage(string $username, string $date, string $fileName): void + private function deleteDashboardImage(string $username, string $fileName): void { $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); if (is_file($path)) { @@ -814,6 +850,41 @@ final class App exit; } + private function serveEventImage(): void + { + $user = $this->requireUser(); + $date = (string) ($_GET['date'] ?? ''); + $eventID = trim((string) ($_GET['id'] ?? '')); + + if (!$this->isValidDate($date) || $eventID === '') { + http_response_code(404); + exit('Nicht gefunden'); + } + + $entry = $this->entries->find($user['username'], $date); + foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) { + if (!is_array($event) || (string) ($event['id'] ?? '') !== $eventID) { + continue; + } + + $fileName = trim((string) ($event['image'] ?? '')); + $path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName); + if ($fileName === '' || !is_file($path)) { + break; + } + + $mime = mime_content_type($path) ?: 'application/octet-stream'; + header('Content-Type: ' . $mime); + header('Content-Length: ' . (string) filesize($path)); + header('Cache-Control: private, max-age=3600'); + readfile($path); + exit; + } + + http_response_code(404); + exit('Nicht gefunden'); + } + private function showTrack(): void { $user = $this->requireUser(); @@ -1098,11 +1169,16 @@ final class App } } + $optionsOpenPanel = trim((string) ($_GET['panel'] ?? '')); + if ($optionsOpenPanel === 'score') { + $optionsOpenPanel = ''; + } + View::render('options', [ 'pageTitle' => 'Optionen', 'page' => 'options', 'authUser' => $user, - 'optionsOpenPanel' => trim((string) ($_GET['panel'] ?? '')), + 'optionsOpenPanel' => $optionsOpenPanel, 'settings' => $settings, 'sportTypePresets' => $sportTypePresets, 'sportLocationOptions' => sport_location_options(), diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 091486e..ffa97ef 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -205,6 +205,7 @@ final class EntryRepository $eventLines[] = '- Wert: ' . (string) ($event['value'] ?? 0); $eventLines[] = '- Einheit: ' . (string) ($event['unit'] ?? ''); $eventLines[] = '- Sportart: ' . (string) ($event['sport_type_id'] ?? ''); + $eventLines[] = '- Bild: ' . (string) ($event['image'] ?? ''); $eventLines[] = '- Getrunken: ' . (!empty($event['consumed']) ? 'ja' : 'nein'); $eventLines[] = '- Kommentar: ' . (string) ($event['comment'] ?? ''); $eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0); @@ -313,6 +314,7 @@ final class EntryRepository 'value' => (float) ($this->extract('/^- Wert:\s*(.*)$/mu', $block) ?? 0), 'unit' => (string) ($this->extract('/^- Einheit:\s*(.*)$/mu', $block) ?? ''), 'sport_type_id' => (string) ($this->extract('/^- Sportart:\s*(.*)$/mu', $block) ?? ''), + 'image' => $this->normalizeImageFileName((string) ($this->extract('/^- Bild:\s*(.*)$/mu', $block) ?? '')), 'consumed' => in_array(strtolower((string) ($this->extract('/^- Getrunken:\s*(.*)$/mu', $block) ?? 'ja')), ['ja', 'yes', 'true', '1'], true), 'comment' => (string) ($this->extract('/^- Kommentar:\s*(.*)$/mu', $block) ?? ''), 'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0), @@ -346,4 +348,11 @@ final class EntryRepository return trim((string) ($matches[1] ?? '')); } + + private function normalizeImageFileName(string $fileName): string + { + $fileName = trim($fileName); + + return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : ''; + } } diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index edd91a5..3e6e07b 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -398,6 +398,7 @@ final class ScoringService 'value' => max(0, min(50000, $value)), 'unit' => $unit, 'sport_type_id' => trim((string) ($event['sport_type_id'] ?? '')), + 'image' => trim((string) ($event['image'] ?? '')), 'consumed' => $this->normalizeBoolean($event['consumed'] ?? true), 'mood' => normalize_signal_value($event['mood'] ?? 0), 'energy' => normalize_signal_value($event['energy'] ?? 0), diff --git a/src/helpers.php b/src/helpers.php index e7f4dc5..be55b96 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -706,7 +706,7 @@ function day_event_type_options(): array { return [ 'event' => [ - 'label' => 'Ereignis', + 'label' => 'Moment', 'icon' => '/assets/icons/activity-event.svg', 'unit' => '', ], diff --git a/templates/layout.php b/templates/layout.php index da98097..5d3f5c6 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -11,7 +11,7 @@ $brandSubtitle = match ($page) { 'setup' => 'Erstkonfiguration', default => 'Stimmungstracker', }; -$immersiveDashboard = $page === 'dashboard'; +$immersiveDashboard = in_array($page, ['dashboard', 'options'], true); $cssVersion = is_file(base_path('assets/css/app.css')) ? (string) filemtime(base_path('assets/css/app.css')) : '1'; $jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_path('assets/js/app.js')) : '1'; ?> diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index 1b71ffa..4830bd1 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -69,8 +69,21 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a - + + + 0 ? rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit'] : ''; ?> + (string) ($sportType['label'] ?? 'Sport'), + 'walk' => 'Spaziergang', + 'sleep' => 'Schlaf', + default => (string) ($item['comment'] !== '' ? $item['comment'] : day_event_type_label($eventType)), + }; ?> + trim($eventValueText), + 'walk', 'sleep' => trim($eventValueText), + default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')), + }; ?> (string) ($item['id'] ?? ''), 'type' => (string) ($item['type'] ?? 'event'), @@ -79,39 +92,35 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a 'value' => (float) ($item['value'] ?? 0), 'unit' => (string) ($item['unit'] ?? ''), 'sport_type_id' => (string) ($item['sport_type_id'] ?? ''), + 'image' => (string) ($item['image'] ?? ''), 'consumed' => !empty($item['consumed']), 'mood' => normalize_signal_value($item['mood'] ?? 0), 'energy' => normalize_signal_value($item['energy'] ?? 0), 'stress' => normalize_signal_value($item['stress'] ?? 0), ]); ?> -
+ +
+ + + +
- +
-

- - - - - -

- 0): ?> +

+

- - - - · - +

- -

+ +

@@ -121,8 +130,10 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?> - + + + <?= e($label) ?> = 0 ? '+' : '' ?> @@ -142,6 +153,15 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a +
-