Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/BACKUPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions scripts/db-backup-push.sh
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 5 additions & 5 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -209,26 +209,26 @@ function onSplashFinish() {
<!-- Bottom tab bar -->
<div v-if="!showSplash" class="bottom-tabs line-anim">
<div class="tab-indicator" :style="tabIndicatorStyle"></div>
<button :class="['tab-btn', { active: activeTab === 'map' && !friendsStore.viewingFriendId }, { 'build-anim build-anim--slide build-anim--delay-1': splashFinished }]" aria-label="Map" @click="switchTab('map')">
<button :class="['tab-btn', 'tab--map', { active: activeTab === 'map' && !friendsStore.viewingFriendId }, { 'build-anim build-anim--slide build-anim--delay-1': splashFinished }]" aria-label="Map" @click="switchTab('map')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
<line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
</svg>
<span>Map</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'search' }, { 'build-anim build-anim--slide build-anim--delay-2': splashFinished }]" aria-label="Search" @click="switchTab('search')">
<button :class="['tab-btn', 'tab--search', { active: activeTab === 'search' }, { 'build-anim build-anim--slide build-anim--delay-2': splashFinished }]" aria-label="Search" @click="switchTab('search')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<span>Search</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'places' }, { 'build-anim build-anim--slide build-anim--delay-3': splashFinished }]" aria-label="My Places" @click="switchTab('places')">
<button :class="['tab-btn', 'tab--places', { active: activeTab === 'places' }, { 'build-anim build-anim--slide build-anim--delay-3': splashFinished }]" aria-label="My Places" @click="switchTab('places')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
<span>Places</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'friends' || friendsStore.viewingFriendId }, { 'build-anim build-anim--slide build-anim--delay-4': splashFinished }]" aria-label="Friends" @click="switchTab('friends')" style="position: relative">
<button :class="['tab-btn', 'tab--friends', { active: activeTab === 'friends' || friendsStore.viewingFriendId }, { 'build-anim build-anim--slide build-anim--delay-4': splashFinished }]" aria-label="Friends" @click="switchTab('friends')" style="position: relative">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
Expand All @@ -238,7 +238,7 @@ function onSplashFinish() {
<span>Friends</span>
<span v-if="friendsStore.pendingCount > 0" class="tab-badge">{{ friendsStore.pendingCount }}</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'profile' }, { 'build-anim build-anim--slide build-anim--delay-4': splashFinished }]" aria-label="Profile" @click="switchTab('profile')">
<button :class="['tab-btn', 'tab--profile', { active: activeTab === 'profile' }, { 'build-anim build-anim--slide build-anim--delay-4': splashFinished }]" aria-label="Profile" @click="switchTab('profile')">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
Expand Down
45 changes: 40 additions & 5 deletions src/categoryIcons.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,42 @@ export function categoryIconSvg(iconName, { size = 15, strokeWidth = 2.6 } = {})
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"><path d="${iconPathFor(iconName)}"></path></svg>`
}

/* 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). */
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
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/components/FriendsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,18 @@ function getInitial(name) {
<template>
<div class="friends-view">
<!-- Header -->
<div class="fv-header">
<h1 class="fv-title">Friends</h1>
<div class="fv-header view-hero hero--friends">
<span class="view-hero-icon">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
</span>
<div class="view-hero-text">
<h1 class="fv-title">Friends</h1>
</div>
<span class="fv-count">{{ friendsStore.friends.length }}</span>
</div>

Expand Down
39 changes: 21 additions & 18 deletions src/components/MapView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
})

Expand Down Expand Up @@ -443,7 +442,7 @@ function setupClickHandlers() {
const html = `
<div class="place-preview nearby">
<div class="pp-header">
<span class="pp-nearby-dot"></span>
<span class="pp-cat-icon nearby" style="color:${amenityStyle(props.amenity).color};border-color:${amenityStyle(props.amenity).color}">${categoryIconSvg(amenityStyle(props.amenity).icon, { size: 13 })}</span>
<span class="pp-name">${props.name}</span>
</div>
<div class="pp-meta">
Expand Down Expand Up @@ -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 || '',
},
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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', () => {
Expand Down
13 changes: 10 additions & 3 deletions src/components/PlacesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,16 @@ function contextAction(action) {
</div>

<!-- Header -->
<div class="pv-header">
<h1 class="pv-title">{{ friendsStore.viewingFriendId ? (friendsStore.viewingFriendInfo?.name?.split(' ')[0] || 'Friend') + "'s Places" : 'My Places' }}</h1>
<span class="pv-count">{{ activePlaceCount }} saved</span>
<div class="pv-header view-hero hero--places">
<span class="view-hero-icon">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
</span>
<div class="view-hero-text">
<h1 class="pv-title">{{ friendsStore.viewingFriendId ? (friendsStore.viewingFriendInfo?.name?.split(' ')[0] || 'Friend') + "'s Places" : 'My Places' }}</h1>
<span class="pv-count">{{ activePlaceCount }} saved</span>
</div>
<button v-if="!friendsStore.viewingFriendId" class="pv-select-btn" :class="{ on: selectMode }" @click="toggleSelectMode">
{{ selectMode ? 'Cancel' : 'Select' }}
</button>
Expand Down
13 changes: 10 additions & 3 deletions src/components/SearchView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,16 @@ function getCatStyle(catId) {
</div>

<!-- Header -->
<div :class="['sv-header', { collapsed: scrolled }]">
<h1 class="sv-title">Discover</h1>
<span class="sv-subtitle">Find new places on OpenStreetMap</span>
<div :class="['sv-header', 'view-hero', 'hero--search', { collapsed: scrolled }]">
<span class="view-hero-icon">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</span>
<div class="view-hero-text">
<h1 class="sv-title">Discover</h1>
<span class="sv-subtitle">Find new places on OpenStreetMap</span>
</div>
</div>

<!-- Search input -->
Expand Down
Loading
Loading