diff --git a/README.md b/README.md index 1f57540..fd5ebb4 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,81 @@ -# Pinna +
-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) + +
+ +--- + +## 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) | + +
+ +![Profile](./assets/Profil..png) + +
## Getting Started @@ -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. diff --git a/assets/Friends.png b/assets/Friends.png index 9e21c79..bf0c048 100644 Binary files a/assets/Friends.png and b/assets/Friends.png differ diff --git a/assets/Global-Overview.png b/assets/Global-Overview.png index c96369e..588b811 100644 Binary files a/assets/Global-Overview.png and b/assets/Global-Overview.png differ diff --git a/assets/Map-Screen.png b/assets/Map-Screen.png index a308652..ee7b91a 100644 Binary files a/assets/Map-Screen.png and b/assets/Map-Screen.png differ diff --git a/assets/MyPlaces.png b/assets/MyPlaces.png index dfe00be..6aae2ff 100644 Binary files a/assets/MyPlaces.png and b/assets/MyPlaces.png differ diff --git a/assets/Place-Preview.png b/assets/Place-Preview.png index 2c9a13f..0062a6a 100644 Binary files a/assets/Place-Preview.png and b/assets/Place-Preview.png differ diff --git a/assets/Profil..png b/assets/Profil..png index ec31d44..38354c8 100644 Binary files a/assets/Profil..png and b/assets/Profil..png differ diff --git a/assets/Search.png b/assets/Search.png index 1bf6086..7fd6404 100644 Binary files a/assets/Search.png and b/assets/Search.png differ diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..7fc6a52 Binary files /dev/null and b/assets/demo.gif differ diff --git a/server/routes/friends.js b/server/routes/friends.js index 6a0031c..cbe8353 100644 --- a/server/routes/friends.js +++ b/server/routes/friends.js @@ -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) { diff --git a/src/App.vue b/src/App.vue index 3716496..7048225 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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) @@ -154,47 +210,54 @@ function onSplashFinish() {
-
- - -
- -
- -
- - +
+ - - - - - - +
+ - - +
+ +
+ + + + + + + + + + + + +
@@ -206,8 +269,14 @@ function onSplashFinish() {
- -
+ +
@@ -272,6 +305,11 @@ function formatDate(ts) { {{ place.cuisine }}
+ +
+ {{ tag }} +
+
- -
- -
+ +
+
+ + + + + - -
+
+ +
@@ -373,51 +424,43 @@ function formatDate(ts) {
-
- Coordinates - {{ place.lat.toFixed(5) }}, {{ place.lng.toFixed(5) }} -
+ + + + + +
+
- -
-
- + +
+
+ + -
-
- Notes - {{ place.notes }} -
-
- - -
-
- - - - - - -
-
- Added - {{ formatDate(place.createdAt) }} -
-
-
- - -
- Tags -
- {{ tag }} + + Notes
+

{{ place.notes }}

@@ -700,7 +743,7 @@ function formatDate(ts) { .place-name-row { display: flex; - align-items: center; + align-items: flex-start; gap: 11px; margin-bottom: 8px; } @@ -718,6 +761,14 @@ function formatDate(ts) { svg { display: block; } } +.place-name-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + .place-name { font-size: 24px; font-weight: 800; @@ -726,6 +777,23 @@ function formatDate(ts) { min-width: 0; } +.place-added { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + width: fit-content; +} + +.hero-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 4px; +} + .place-rating { display: flex; align-items: center; @@ -937,6 +1005,19 @@ function formatDate(ts) { border-bottom: 1px solid var(--border); &:last-child { border-bottom: none; } + + &.info-row--copy { + width: 100%; + text-align: left; + /* don't touch background here โ€” cartoon.scss owns the card + background; a scoped reset previously out-specified it and made + this row fall through to the page backdrop instead of staying + white like its siblings */ + border-radius: inherit; + cursor: pointer; + + &:active { transform: scale(0.99); } + } } .info-icon { @@ -956,6 +1037,129 @@ function formatDate(ts) { flex-direction: column; gap: 2px; min-width: 0; + flex: 1; +} + +.info-copy-icon { + flex-shrink: 0; + align-self: center; + color: var(--text-muted); + opacity: 0.6; +} + +.info-row--copy:active .info-copy-icon { opacity: 1; color: var(--accent); } + +/* โ”€โ”€ Address + Coordinates: one pill, coordinates collapsed โ”€โ”€ */ +.addr-card { + margin: 0 22px 12px; + border-radius: 16px; + overflow: hidden; +} + +.addr-row { + display: flex; + align-items: flex-start; + gap: 14px; + width: 100%; + padding: 12px; + text-align: left; + cursor: pointer; + background: transparent; // reset the browser's default button-face gray + border: none; + font: inherit; + color: inherit; + + &:active { transform: scale(0.99); } +} + +.addr-divider { + height: 1px; + margin: 0 12px; +} + +.coords-toggle { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 12px; + text-align: left; + cursor: pointer; + + &:active { opacity: 0.8; } +} + +.coords-toggle-label { + flex: 1; + font-size: 13px; + font-weight: 700; +} + +.coords-chevron { + flex-shrink: 0; +} + +/* Expanded state: the label is replaced in place by the value + a + copy action โ€” same row, same height, no accordion panel below */ +.coords-value { + flex: 1; + font-size: 13.5px; + font-weight: 700; + word-break: break-word; + animation: ct-pop 0.15s ease both; +} + +.coords-copy-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + font-family: inherit; + font-size: 12px; + font-weight: 700; + border: none; + cursor: pointer; + + &:active { transform: scale(0.96); } +} + +/* โ”€โ”€ Notes: bigger, distinct "note" card โ”€โ”€ */ +.notes-card { + margin: 0 22px 12px; + padding: 18px 20px; + border-radius: 20px; +} + +.notes-card-head { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 10px; +} + +.notes-card-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; +} + +.notes-card-label { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.notes-card-text { + font-size: 15.5px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; } .info-label { diff --git a/src/components/PlacesView.vue b/src/components/PlacesView.vue index 2324f82..43bf087 100644 --- a/src/components/PlacesView.vue +++ b/src/components/PlacesView.vue @@ -1,5 +1,5 @@