From b536281d12930e30a9e84fe16e2f4ffe500f83ef Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:17:28 +0200 Subject: [PATCH 1/3] Automated off-site DB backups: daily cron -> private pinna-db-backups repo --- docs/BACKUPS.md | 9 +++++++++ scripts/db-backup-push.sh | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100755 scripts/db-backup-push.sh diff --git a/docs/BACKUPS.md b/docs/BACKUPS.md index 009837f..1a4011b 100644 --- a/docs/BACKUPS.md +++ b/docs/BACKUPS.md @@ -30,3 +30,12 @@ gunzip -c backups/pinna-XXXX.sql.gz | psql "$DATABASE_URL" - Never point `prisma migrate diff/dev --shadow-database-url` at a real database — Prisma **resets** shadow databases. Use a throwaway local container instead. - Take a manual backup before any schema change: `./scripts/db-backup.sh`. + +## Current automated setup (active since 2026-02) + +- `scripts/db-backup-push.sh` runs **daily at 03:00** via cron on the dev machine: + dump → `backups/` (local, keeps 30) → `~/Backups/pinna` → pushed to the + **private** repo `Osyna/pinna-db-backups` (keeps 60 snapshots off-site). +- Verified restore path: `gunzip -c pinna-*.sql.gz | psql "$DATABASE_URL"`. +- Recommended upgrade when available: S3 destination in Dokploy for + server-side scheduled backups independent of the dev machine. diff --git a/scripts/db-backup-push.sh b/scripts/db-backup-push.sh new file mode 100755 index 0000000..79cbce3 --- /dev/null +++ b/scripts/db-backup-push.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Dump the Pinna DB and push the snapshot to the PRIVATE off-site repo +# (github.com/Osyna/pinna-db-backups). Intended for cron: +# 0 3 * * * /home/irvin/Projects/Pinna/scripts/db-backup-push.sh >> /home/irvin/Backups/pinna/cron.log 2>&1 +set -euo pipefail +cd "$(dirname "$0")/.." + +./scripts/db-backup.sh + +DEST="$HOME/Backups/pinna" +mkdir -p "$DEST" +cp backups/pinna-*.sql.gz "$DEST"/ 2>/dev/null || true + +cd "$DEST" +# keep the 60 newest off-site +ls -1t pinna-*.sql.gz 2>/dev/null | tail -n +61 | xargs -r rm -- +git add -A +git -c user.name=pinna-backup -c user.email=7606712+Osyna@users.noreply.github.com \ + commit -q -m "DB backup $(date +%F-%H%M)" || exit 0 # nothing new +git push -q origin main +echo "$(date -Is) backup pushed" From c3995a21b8dbb4ae1d4046793505cb0e9a19be0d Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:23:34 +0200 Subject: [PATCH 2/3] Nearby search results: typed icon markers with dotted 'not saved' ring - Nearby OSM results render as the same white icon discs as saved places (amenity -> icon+color mapping: restaurant/bar/pub/cafe/ fast-food/bakery/nightclub/ice-cream...), but with a DOTTED ring in the type color to signal 'found nearby, not saved yet' - drawMarkerImage gains a dashed option; images generated lazily via styleimagemissing (nearby-marker| prefix), sized 30px vs 34px saved - nearby-circles layer switched circle -> symbol (same id, handlers untouched); stale circle-color updater removed - Nearby popup shows a dashed round icon chip in the type color instead of the amber dot --- src/categoryIcons.js | 45 +++++++++++++++++++++++++++++++++----- src/components/MapView.vue | 39 ++++++++++++++++++--------------- src/styles/main.scss | 7 ++++++ 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/categoryIcons.js b/src/categoryIcons.js index 59bb499..837b931 100644 --- a/src/categoryIcons.js +++ b/src/categoryIcons.js @@ -32,6 +32,30 @@ export function categoryIconSvg(iconName, { size = 15, strokeWidth = 2.6 } = {}) return `` } +/* OSM amenity -> icon + color (same palette as saved categories) */ +export const AMENITY_STYLE = { + restaurant: { icon: 'utensils', color: '#FF3B30' }, + bar: { icon: 'glass', color: '#AF52DE' }, + pub: { icon: 'glass', color: '#AF52DE' }, + biergarten: { icon: 'glass', color: '#AF52DE' }, + cafe: { icon: 'coffee', color: '#FF9500' }, + fast_food: { icon: 'burger', color: '#FFCC00' }, + food_court: { icon: 'burger', color: '#FFCC00' }, + ice_cream: { icon: 'brunch', color: '#FF2D55' }, + bakery: { icon: 'bread', color: '#A0522D' }, + nightclub: { icon: 'music', color: '#5856D6' }, +} + +export function amenityStyle(amenity) { + return AMENITY_STYLE[amenity] || { icon: 'pin', color: '#8E8E93' } +} + +/* MapLibre image id for an unsaved nearby place (dashed ring) */ +export function nearbyImageId(amenity) { + const { icon, color } = amenityStyle(amenity) + return `nearby-marker|${icon}|${color}` +} + /* Stable MapLibre image id for a category (icon + color are both encoded, so custom categories that share an icon but differ in color still get their own image). */ @@ -39,9 +63,11 @@ export function markerImageId(cat) { return `cat-marker|${cat?.icon || 'pin'}|${cat?.color || '#8E8E93'}` } -/* Draw a cartoon map marker: white disc, ink outline, colored icon. +/* Draw a cartoon map marker: white disc, soft ring, colored icon. + `dashed: true` renders the ring dotted — used for places that are + found nearby but NOT saved yet. Returns ImageData + pixelRatio, ready for map.addImage(). */ -export function drawMarkerImage(iconName, color, { size = 34, pixelRatio = 2 } = {}) { +export function drawMarkerImage(iconName, color, { size = 34, pixelRatio = 2, dashed = false } = {}) { const px = size * pixelRatio const canvas = document.createElement('canvas') canvas.width = px @@ -60,9 +86,18 @@ export function drawMarkerImage(iconName, color, { size = 34, pixelRatio = 2 } = ctx.shadowColor = 'transparent' ctx.shadowBlur = 0 ctx.shadowOffsetY = 0 - ctx.lineWidth = 1.6 * pixelRatio - ctx.strokeStyle = 'rgba(46, 33, 64, 0.30)' - ctx.stroke() + if (dashed) { + // dotted ring in the type color: "found nearby, not saved yet" + ctx.setLineDash([3.2 * pixelRatio, 2.6 * pixelRatio]) + ctx.lineWidth = 1.9 * pixelRatio + ctx.strokeStyle = color + ctx.stroke() + ctx.setLineDash([]) + } else { + ctx.lineWidth = 1.6 * pixelRatio + ctx.strokeStyle = 'rgba(46, 33, 64, 0.30)' + ctx.stroke() + } // Icon, centered (24-unit viewBox) const span = size * 0.56 * pixelRatio diff --git a/src/components/MapView.vue b/src/components/MapView.vue index 6d39bdb..63ce840 100644 --- a/src/components/MapView.vue +++ b/src/components/MapView.vue @@ -13,7 +13,7 @@ import { useUserLocation } from '../composables/useUserLocation' import { useTheme } from '../composables/useTheme' import { hapticTap } from '../composables/useHaptics' import { ACCENT } from '../theme' -import { iconPathFor, categoryIconSvg, markerImageId, drawMarkerImage } from '../categoryIcons' +import { iconPathFor, categoryIconSvg, markerImageId, drawMarkerImage, amenityStyle, nearbyImageId } from '../categoryIcons' const props = defineProps({ splashFinished: Boolean @@ -275,19 +275,18 @@ function setupCustomLayers() { paint: { 'text-color': '#ffffff' }, }) - // Nearby individual circles (exclude clusters) + // Nearby individual markers (exclude clusters): dashed-ring icon discs — + // same type colors as saved places, dotted outline = "not saved yet" map.addLayer({ id: 'nearby-circles', - type: 'circle', + type: 'symbol', source: 'nearby', filter: ['!', ['has', 'point_count']], - paint: { - 'circle-radius': 6, - 'circle-color': nearbyColor, - 'circle-stroke-width': 1.5, - 'circle-stroke-color': 'rgba(255, 255, 255, 0.7)', - 'circle-opacity': 0.8, - 'circle-stroke-opacity': 0.8, + layout: { + 'icon-image': ['get', 'iconId'], + 'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.8, 15, 1], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, }, }) @@ -443,7 +442,7 @@ function setupClickHandlers() { const html = `
- + ${categoryIconSvg(amenityStyle(props.amenity).icon, { size: 13 })} ${props.name}
@@ -546,6 +545,7 @@ function renderNearbyMarkers() { lat: place.lat, lng: place.lng, amenity: place.amenity, + iconId: nearbyImageId(place.amenity), cuisine: place.cuisine || '', address: place.address || '', }, @@ -728,9 +728,6 @@ function switchTileLayer(name) { function updateLayerColors() { if (!map) return const nearbyColor = theme.value === 'light' ? '#0891b2' : '#22d3ee' - if (map.getLayer('nearby-circles')) { - map.setPaintProperty('nearby-circles', 'circle-color', nearbyColor) - } if (map.getLayer('nearby-clusters')) { map.setPaintProperty('nearby-clusters', 'circle-color', nearbyColor) } @@ -971,10 +968,16 @@ onMounted(() => { // Category marker icons are generated lazily (and re-generated after every // style switch, which wipes images): white disc + ink outline + colored icon. map.on('styleimagemissing', (e) => { - if (!e.id.startsWith('cat-marker|') || map.hasImage(e.id)) return - const [, icon, color] = e.id.split('|') - const { imageData, pixelRatio } = drawMarkerImage(icon, color) - map.addImage(e.id, imageData, { pixelRatio }) + if (map.hasImage(e.id)) return + if (e.id.startsWith('cat-marker|')) { + const [, icon, color] = e.id.split('|') + const { imageData, pixelRatio } = drawMarkerImage(icon, color) + map.addImage(e.id, imageData, { pixelRatio }) + } else if (e.id.startsWith('nearby-marker|')) { + const [, icon, color] = e.id.split('|') + const { imageData, pixelRatio } = drawMarkerImage(icon, color, { size: 30, dashed: true }) + map.addImage(e.id, imageData, { pixelRatio }) + } }) map.on('load', () => { diff --git a/src/styles/main.scss b/src/styles/main.scss index 24a7656..7104b05 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -331,6 +331,13 @@ select { justify-content: center; svg { display: block; } + + /* nearby (unsaved) variant: dotted outline in the type color */ + &.nearby { + border-radius: 50%; + border-style: dashed; + border-width: 2px; + } } .pp-nearby-dot { From 240c159b107a40ae4a4816fe13605658ce34a887 Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:29:20 +0200 Subject: [PATCH 3/3] Per-tab identity colors + Profile-style hero headers with icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every navbar tab now has a dedicated color, reused by its view: - Map green, Discover blue, My Places amber, Friends pink, Profile purple (unchanged — it set the pattern) - Active tab pills take their color (tinted glow shadow to match) Each view header is now a Profile-style hero card: - Tinted rounded card with the two decorative corner blobs (identity color + yellow), a white icon chip (view icon in the identity color) and the title in the identity color - Discover keeps its collapse-on-scroll behavior (compact hero) - My Places keeps the count badge + Select button (now amber), Friends keeps the count badge - Base gradient text-fill on titles reset so hero colors apply --- src/App.vue | 10 +-- src/components/FriendsView.vue | 14 +++- src/components/PlacesView.vue | 13 +++- src/components/SearchView.vue | 13 +++- src/styles/cartoon.scss | 128 +++++++++++++++++++++++++++++++-- 5 files changed, 158 insertions(+), 20 deletions(-) diff --git a/src/App.vue b/src/App.vue index 138b9f3..3716496 100644 --- a/src/App.vue +++ b/src/App.vue @@ -209,26 +209,26 @@ function onSplashFinish() {
- - - - - diff --git a/src/components/SearchView.vue b/src/components/SearchView.vue index 5b46c9e..25c47c6 100644 --- a/src/components/SearchView.vue +++ b/src/components/SearchView.vue @@ -251,9 +251,16 @@ function getCatStyle(catId) {
-
-

Discover

- Find new places on OpenStreetMap +
+ + + + + +
+

Discover

+ Find new places on OpenStreetMap +
diff --git a/src/styles/cartoon.scss b/src/styles/cartoon.scss index 369225f..dae0ea7 100644 --- a/src/styles/cartoon.scss +++ b/src/styles/cartoon.scss @@ -197,6 +197,13 @@ html[data-skin="cartoon"] { &:active { transform: translateY(2px); opacity: 1; } } + /* Per-tab identity colors (reused by each view's hero header) */ + .tab--map.active { background: #38c172; box-shadow: 0 4px 12px rgba(56, 193, 114, 0.35); } + .tab--search.active { background: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); } + .tab--places.active { background: #f0a90b; box-shadow: 0 4px 12px rgba(240, 169, 11, 0.35); } + .tab--friends.active { background: #f15bb5; box-shadow: 0 4px 12px rgba(241, 91, 181, 0.35); } + .tab--profile.active { background: $ct-purple; box-shadow: 0 4px 12px rgba(109, 59, 240, 0.35); } + .tab-badge { background: $ct-yellow; color: $ct-ink; @@ -581,14 +588,122 @@ html[data-skin="cartoon"] { &:active { transform: translateY(2px); opacity: 1; } } + /* ── View hero headers (Profile-style, per-tab identity color) ─── */ + .view-hero { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + gap: 12px; + margin: 12px 16px 12px; + padding: 15px 16px; + border-radius: 24px; + box-shadow: $ct-shadow-card; + + /* decorative corner blobs, like the Profile hero */ + &::before, + &::after { + content: ''; + position: absolute; + z-index: 0; + border-radius: 50%; + pointer-events: none; + } + + &::before { + left: -16px; + top: -16px; + width: 58px; + height: 58px; + background: var(--hero-deco, rgba(109, 59, 240, 0.12)); + } + + &::after { + right: -14px; + bottom: -20px; + width: 68px; + height: 68px; + background: rgba(255, 201, 74, 0.40); + } + + > * { position: relative; z-index: 1; } + + h1 { + font-family: $ct-display; + font-weight: 800; + font-size: 26px; + line-height: 1.05; + letter-spacing: 0; + /* base titles use a gradient text-fill — reset it so the hero color applies */ + background: none; + -webkit-background-clip: initial; + background-clip: initial; + -webkit-text-fill-color: currentColor; + color: var(--hero-color, #{$ct-purple}); + margin: 0; + } + } + + .view-hero-icon { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 15px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + color: var(--hero-color, #{$ct-purple}); + box-shadow: $ct-shadow-chip; + } + + .view-hero-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + align-items: flex-start; + } + + .hero--search { + --hero-color: #3b82f6; + --hero-deco: rgba(59, 130, 246, 0.13); + background: #e7f0fe; + } + + .hero--places { + --hero-color: #e8930c; + --hero-deco: rgba(240, 169, 11, 0.16); + background: #fdf3d5; + } + + .hero--friends { + --hero-color: #f15bb5; + --hero-deco: rgba(241, 91, 181, 0.14); + background: #fde7f4; + } + + /* titles take the hero color (out-specify base component rules) */ + .hero--search .sv-title { color: #3b82f6; } + .hero--places .pv-title { color: #e8930c; } + .hero--friends .fv-title { color: #f15bb5; } + /* ── 5 · Discover (SearchView) ─────────────────────────────────── */ .sv-title { font-family: $ct-display; font-weight: 800; - color: $ct-purple; } - .sv-subtitle { font-weight: 700; } + .sv-header.view-hero.collapsed { + padding: 10px 16px; + + .sv-title { font-size: 20px; } + .sv-subtitle { display: none; } + .view-hero-icon { width: 34px; height: 34px; border-radius: 12px; } + } + + .sv-subtitle { font-weight: 700; color: $ct-body; font-size: 12.5px; } .sv-search-wrap, .sv-city-wrap, @@ -675,7 +790,6 @@ html[data-skin="cartoon"] { .pv-title { font-family: $ct-display; font-weight: 800; - color: $ct-purple; } .pv-count { @@ -769,14 +883,15 @@ html[data-skin="cartoon"] { background: #fff; border: none; border-radius: 13px; - padding: 5px 13px; + padding: 6px 13px; font-weight: 900; font-size: 12px; - color: $ct-purple; + color: #e8930c; box-shadow: $ct-shadow-chip; cursor: pointer; + flex-shrink: 0; - &.on { background: $ct-lavender; } + &.on { background: #fdf3cd; } } .pv-card.selecting { opacity: 0.94; } @@ -1022,7 +1137,6 @@ html[data-skin="cartoon"] { .fv-title { font-family: $ct-display; font-weight: 800; - color: $ct-purple; } .fv-count {