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
105 changes: 73 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,81 @@
# Pinna
<div align="center">

Open-source mapping application with a playful **cartoon-game UI** — chunky ink outlines, cream paper surfaces, and press-down buttons. Built with Vue 3, Vite, and MapLibre GL on OpenStreetMap data: save the places you love, organize them by category, and share your map with friends.
# 📍 Pinna

![Map Overview](./assets/Global-Overview.png)
### The place-saving app that actually feels fun to use.

Stop losing your favorite spots in a boring list. Save them on a map that has personality — chunky ink outlines, a cream-paper world, and buttons that press down like they mean it. Open source, self-hostable, and built to be shared with the people you actually travel and eat with.

[![License: GPLv3](https://img.shields.io/badge/license-GPLv3-8b6bff.svg)](./LICENSE)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![MapLibre GL](https://img.shields.io/badge/MapLibre-GL-6d3bf0)](https://maplibre.org)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-ffc94a.svg)](https://github.com/Osyna/Pinna/pulls)

**[✨ Try the live demo →](https://maps.osyna.sh)**

![Pinna demo](./assets/demo.gif)

</div>

---

## Why Pinna?

Every mapping app lets you "save a place." Almost none of them make you *want* to open the app again.

- 🎨 **It's actually delightful.** A hand-tuned cartoon UI — not a reskinned Google Maps clone — with motion, color, and personality on every screen.
- 🗺️ **Your places, organized like they matter.** Categories, star ratings, tags, notes, and custom lists ("Someday, Somewhere") — not a flat pin dump you'll never scroll through again.
- 👯 **Built to share.** Add a friend by their ID and their pins land right on your map. See who else has already been to the place you're about to try.
- ♿ **Accessible by design.** Every category is an icon *and* a color — never color alone — on markers, legends, and popups.
- 🧩 **Actually open.** No walled garden, no ads, no tracking. Self-host it in five minutes with Docker, or fork it and make it yours.
- ⚡ **Fast, for real.** Hundreds of saved places render instantly with dynamic loading, a shared geo-cache, and offline-ready map tiles — it stays snappy at any scale.

## 🚀 Live Demo

You can try the live demo at: **[maps.osyna.com](https://maps.osyna.com)**
Try it right now, no install needed: **[maps.osyna.sh](https://maps.osyna.sh)**

**Credentials for testing:**
**Shared demo login** (a public sandbox — feel free to explore and add a few pins of your own):
- **Email:** `test@test.com`
- **Password:** `test@test.com`

*It's a shared account, so please be kind to it — for your own private map, registering a free account takes ten seconds.*

## Features

- **Cartoon Game UI** - The app's signature look: 3px ink outlines, cream paper, `Baloo 2` display type, pop/bob animations and buttons that physically press down.
- **Interactive Map** - MapLibre GL with Default, Streets, and Satellite tile layers, smooth fly-to animations and marker clustering.
- **Icons + Colors** - Every category is encoded by an icon *and* a color — on map markers, in the legend, in popups — never by color alone (color-blind friendly).
- **Place Search** - Search addresses via Nominatim and discover restaurants, cafes, bars and more from OpenStreetMap, worldwide or near you.
- **Save Places** - Tap anywhere on the map to save a spot with notes, rating, cuisine, tags and website.
- **Categories** - 13 built-in categories (Restaurant, Bar, Cafe, Bakery, Nature, Culture, Hotel…), each with its own icon and color; fully customizable.
- **Friends** - Add friends by user ID, browse their maps, and see when friends saved the same place as you.
- **Directions** - Driving routes to any saved place using OSRM.
- **Import/Export** - Export all saved places to JSON, import from file.
- **Accounts & Sync** - JWT-authenticated accounts with avatars, favorite cuisines and favorite places, backed by PostgreSQL.
| | |
|---|---|
| 🎮 **Cartoon Game UI** | 3px ink outlines, cream paper, `Baloo 2` display type, pop/bob animations, and buttons that physically press down. Every tab has its own identity color that carries through its whole screen. |
| 🗺️ **Interactive Map** | MapLibre GL with Default, Streets, and Satellite layers, buttery fly-to animations, and marker clustering that scales to thousands of pins. |
| 🎯 **Icons + Colors, Always Together** | Every category — on the map, in the legend, in popups — is encoded by an icon *and* a color. Never color alone. |
| 🔍 **Real Place Search** | Search addresses via Nominatim and discover restaurants, cafés, bars, and more from OpenStreetMap, anywhere in the world or right around you. |
| 📌 **Save Anything, Fast** | Tap anywhere on the map to save a spot with notes, a star rating, cuisine, tags, and a website link. |
| 🗂️ **Categories & Lists** | 13 built-in categories, each with its own icon and color, fully customizable — plus custom lists like "Someday, Somewhere" to plan future trips. |
| 👥 **Friends** | Add friends by their user ID, browse their maps, and see at a glance when a friend has already saved the place you're looking at. |
| 🧭 **Directions** | One-tap driving routes to any saved place via OSRM. |
| 🗑️ **Recoverable Deletes** | Deleted a place by mistake? It sits in Recently Deleted for 30 days before it's really gone. |
| 📤 **Import & Export** | Export everything to JSON, import from file — including Google Maps Takeout and Mapstr exports. |
| 🔐 **Real Accounts** | JWT-authenticated accounts with avatars, favorite cuisines, and favorite places, backed by PostgreSQL — your data is actually yours. |
| 📱 **Installable & Offline-Ready** | A real PWA: install it to your home screen, and browse your saved places and cached map tiles even with no signal. |

## Screenshots

| Map View | Place Details |
| A world of saved places | Every tab, its own color |
| :---: | :---: |
| ![Map Screen](./assets/Map-Screen.png) | ![Place Preview](./assets/Place-Preview.png) |
| ![Map Overview](./assets/Global-Overview.png) | ![Map Screen](./assets/Map-Screen.png) |

| Search | My Places |
| Rich place details | Search & discover |
| :---: | :---: |
| ![Search](./assets/Search.png) | ![My Places](./assets/MyPlaces.png) |
| ![Place Preview](./assets/Place-Preview.png) | ![Search](./assets/Search.png) |

| Friends | Profile |
| Your places, organized | Places are better shared |
| :---: | :---: |
| ![Friends](./assets/Friends.png) | ![Profile](./assets/Profil..png) |
| ![My Places](./assets/MyPlaces.png) | ![Friends](./assets/Friends.png) |

<div align="center">

![Profile](./assets/Profil..png)

</div>

## Getting Started

Expand Down Expand Up @@ -75,20 +113,23 @@ docker build -t pinna .
docker run -p 8080:80 -e DATABASE_URL=postgres://... pinna
```

Then open http://localhost:8080
Then open http://localhost:8080 — your own Pinna, running in one command.

## Tech Stack

- **Vue 3** - Composition API for modern frontend logic.
- **Vite** - High-performance build tool.
- **MapLibre GL** - GPU-accelerated vector map rendering.
- **Pinia** - Intuitive state management.
- **OpenStreetMap** - Global open tile data.
- **Nominatim** - Accurate geocoding & search.
- **OSRM** - Efficient routing service.
- **Express + Prisma** - REST API with PostgreSQL persistence and JWT auth.
- **Nginx** - Production-ready serving (Docker).
| Frontend | Backend | Infra |
|---|---|---|
| Vue 3 (Composition API) | Express | PostgreSQL + Prisma |
| Vite | JWT auth | Nginx (Docker) |
| MapLibre GL | Nominatim / Overpass / OSRM (self-cached proxy) | GitHub Actions CI |
| Pinia | | |

## Design

The cartoon UI was designed in [Claude Design](https://claude.ai/design) ("Cartoon game UI overhaul") and implemented 1:1 in Vue: the design tokens, category icon set and motion language live in `src/styles/cartoon.scss` and `src/categoryIcons.js`.
The cartoon UI was designed in [Claude Design](https://claude.ai/design) ("Cartoon game UI overhaul") and implemented 1:1 in Vue: the design tokens, category icon set, and motion language live in [`src/styles/cartoon.scss`](./src/styles/cartoon.scss) and [`src/categoryIcons.js`](./src/categoryIcons.js).

## Contributing

Pinna is open source under GPLv3 — issues, pull requests, and forks are genuinely welcome. If you build something with it or find a bug, [open an issue](https://github.com/Osyna/Pinna/issues).

If Pinna made you smile, a ⭐ on the repo goes a long way.
Binary file modified assets/Friends.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/Global-Overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/Map-Screen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/MyPlaces.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/Place-Preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/Profil..png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/Search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion server/routes/friends.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const requestLimiter = rateLimit({
const FRIEND_SELECT = {
id: true, name: true, handle: true,
bio: true, country: true, favoriteCuisines: true,
_count: { select: { places: true } },
_count: { select: { places: { where: { deletedAt: null } } } },
}

export default function friendsRoutes(prisma) {
Expand Down
178 changes: 140 additions & 38 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,62 @@ const mapRef = ref(null)
const activeTab = ref('map')
const detailPlaceId = ref(null)

/* ── Smooth tab transitions + navbar swipe ── */
const TAB_ORDER = ['map', 'search', 'places', 'friends', 'profile']
function effectiveTab(tab) {
// Viewing a friend's map keeps the URL/tab at 'map' but visually belongs to 'friends'
return (tab === 'map' && friendsStore.viewingFriendId) ? 'friends' : tab
}
const slideDirection = ref('next')
watch(activeTab, (next, prev) => {
const a = TAB_ORDER.indexOf(effectiveTab(prev))
const b = TAB_ORDER.indexOf(effectiveTab(next))
slideDirection.value = b >= a ? 'next' : 'prev'
})
const viewTransitionName = computed(() => `view-slide-${slideDirection.value}`)

function goAdjacentTab(delta) {
const idx = TAB_ORDER.indexOf(effectiveTab(activeTab.value))
const nextIdx = Math.min(TAB_ORDER.length - 1, Math.max(0, idx + delta))
const nextTab = TAB_ORDER[nextIdx]
if (nextTab !== effectiveTab(activeTab.value)) switchTab(nextTab)
}

// Swipe left/right ON the tab bar to move to the next/previous view
let tabSwipeStartX = 0
let tabSwipeStartY = 0
let tabSwipeDeltaX = 0
let tabSwiping = false
const SWIPE_THRESHOLD = 40

function onTabsTouchStart(e) {
const t = e.touches[0]
tabSwipeStartX = t.clientX
tabSwipeStartY = t.clientY
tabSwipeDeltaX = 0
tabSwiping = false
}
function onTabsTouchMove(e) {
const t = e.touches[0]
const dx = t.clientX - tabSwipeStartX
const dy = t.clientY - tabSwipeStartY
if (!tabSwiping && Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy)) {
tabSwiping = true
}
if (tabSwiping) tabSwipeDeltaX = dx
}
function onTabsTouchEnd(e) {
if (tabSwiping) {
// Swallow the synthetic click a swipe would otherwise fire on the
// button under the finger (would switch to a random adjacent tab)
e.preventDefault()
if (tabSwipeDeltaX <= -SWIPE_THRESHOLD) goAdjacentTab(1)
else if (tabSwipeDeltaX >= SWIPE_THRESHOLD) goAdjacentTab(-1)
}
tabSwiping = false
tabSwipeDeltaX = 0
}

const isOffline = ref(!navigator.onLine)
const showSplash = ref(true)
const splashFinished = ref(false)
Expand Down Expand Up @@ -154,47 +210,54 @@ function onSplashFinish() {

<div class="app-root" :class="{ 'pre-reveal': !splashFinished }">
<!-- Map (Always rendered, but UI elements inside animate) -->
<div v-show="activeTab === 'map'" class="map-layer">
<MapView
ref="mapRef"
:splash-finished="splashFinished"
@show-detail="onShowDetail"
@add-place="onAddPlace"
/>
<SearchBar
v-if="!showSplash"
:map-ref="mapRef"
:class="{ 'build-anim build-anim--drop build-anim--delay-1': splashFinished }"
@select="onSearchSelect"
/>
</div>

<div v-if="!showSplash" class="main-content-layer">
<AuthModal v-if="!authStore.isAuthenticated && !authStore.loading" />
<div v-else-if="authStore.isAuthenticated" class="tab-content-wrapper">
<!-- Search view -->
<SearchView
v-if="activeTab === 'search'"
<Transition :name="viewTransitionName">
<div v-show="activeTab === 'map'" class="map-layer">
<MapView
ref="mapRef"
:splash-finished="splashFinished"
@show-detail="onShowDetail"
@select="onSearchSelect"
@add-place="onAddPlace"
/>

<!-- Places view -->
<PlacesView
v-if="activeTab === 'places'"
@show-detail="onShowDetail"
@add-place="onAddPlace"
/>

<!-- Friends view -->
<FriendsView
v-if="activeTab === 'friends'"
@view-friend-map="onViewFriendMap"
<SearchBar
v-if="!showSplash"
:map-ref="mapRef"
:class="{ 'build-anim build-anim--drop build-anim--delay-1': splashFinished }"
@select="onSearchSelect"
/>
</div>
</Transition>

<!-- Profile view -->
<ProfileView v-if="activeTab === 'profile'" />
<div v-if="!showSplash" class="main-content-layer">
<AuthModal v-if="!authStore.isAuthenticated && !authStore.loading" />
<div v-else-if="authStore.isAuthenticated" class="tab-content-wrapper">
<Transition :name="viewTransitionName">
<!-- Search view -->
<SearchView
v-if="activeTab === 'search'"
key="search"
@show-detail="onShowDetail"
@select="onSearchSelect"
@add-place="onAddPlace"
/>

<!-- Places view -->
<PlacesView
v-else-if="activeTab === 'places'"
key="places"
@show-detail="onShowDetail"
@add-place="onAddPlace"
/>

<!-- Friends view -->
<FriendsView
v-else-if="activeTab === 'friends'"
key="friends"
@view-friend-map="onViewFriendMap"
/>

<!-- Profile view -->
<ProfileView v-else-if="activeTab === 'profile'" key="profile" />
</Transition>
</div>
</div>

Expand All @@ -206,8 +269,14 @@ function onSplashFinish() {
</div>
</transition>

<!-- Bottom tab bar -->
<div v-if="!showSplash" class="bottom-tabs line-anim">
<!-- Bottom tab bar (swipe left/right to move to the next/previous view) -->
<div
v-if="!showSplash"
class="bottom-tabs line-anim"
@touchstart.passive="onTabsTouchStart"
@touchmove.passive="onTabsTouchMove"
@touchend="onTabsTouchEnd"
>
<div class="tab-indicator" :style="tabIndicatorStyle"></div>
<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">
Expand Down Expand Up @@ -289,6 +358,39 @@ function onSplashFinish() {
.app-root { position: relative; width: 100%; height: 100%; overflow: hidden; }
.map-layer { position: absolute; inset: 0; z-index: 1; }

// ─── Smooth cross-tab transitions ───
// Both the map layer and the tab-content-wrapper's view share these same
// class names (bound to the same computed direction), so switching to/from
// the map tab feels like one continuous motion even though the map is a
// permanently-mounted v-show element and the other views mount/unmount.
.view-slide-next-enter-active,
.view-slide-next-leave-active,
.view-slide-prev-enter-active,
.view-slide-prev-leave-active {
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease;
}

.view-slide-next-enter-from { transform: translateX(18px); opacity: 0; }
.view-slide-next-leave-to { transform: translateX(-14px); opacity: 0; }
.view-slide-prev-enter-from { transform: translateX(-18px); opacity: 0; }
.view-slide-prev-leave-to { transform: translateX(14px); opacity: 0; }

// Reduced motion: keep it a simple, fast crossfade
@media (prefers-reduced-motion: reduce) {
.view-slide-next-enter-active,
.view-slide-next-leave-active,
.view-slide-prev-enter-active,
.view-slide-prev-leave-active {
transition: opacity 0.15s ease;
}
.view-slide-next-enter-from,
.view-slide-next-leave-to,
.view-slide-prev-enter-from,
.view-slide-prev-leave-to {
transform: none;
}
}

// Bottom tab bar
.bottom-tabs {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1100;
Expand Down
Loading
Loading