mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(manacore/web): add places module with GPS location tracking
New local-first places module for the workbench: browser Geolocation API tracking, place management (CRUD, favorites, tags, categories), OSM map preview in detail view, and proximity-based visit detection. Also allows geolocation in Permissions-Policy header (self only). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e17d6228e4
commit
8f5727fd51
15 changed files with 1656 additions and 2 deletions
|
|
@ -229,6 +229,40 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'places',
|
||||
name: 'Places',
|
||||
color: '#0EA5E9',
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/places/ListView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/places/views/DetailView.svelte') },
|
||||
},
|
||||
collection: 'places',
|
||||
paramKey: 'placeId',
|
||||
dragType: 'place',
|
||||
acceptsDropFrom: ['contact'],
|
||||
transformIncoming: {
|
||||
contact: (source) => ({
|
||||
name: `Treffen mit ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
}),
|
||||
},
|
||||
getDisplayData: (item) => ({
|
||||
title: (item.name as string) || 'Ort',
|
||||
subtitle: (item.address as string) ?? undefined,
|
||||
}),
|
||||
createItem: async (data) => {
|
||||
const { placesStore } = await import('$lib/modules/places/stores/places.svelte');
|
||||
const place = await placesStore.createPlace({
|
||||
name: (data.name as string) ?? 'Neuer Ort',
|
||||
latitude: (data.latitude as number) ?? 0,
|
||||
longitude: (data.longitude as number) ?? 0,
|
||||
});
|
||||
return place.id;
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'chat',
|
||||
name: 'Chat',
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ db.version(1).stores({
|
|||
financeCategories: 'id, type, order',
|
||||
budgets: 'id, categoryId, month, [month+categoryId]',
|
||||
|
||||
// ─── Places (appId: 'places') ───
|
||||
places: 'id, name, category, isFavorite, isArchived, latitude, longitude',
|
||||
locationLogs: 'id, placeId, timestamp, [placeId+timestamp]',
|
||||
placeTags: 'id, placeId, tagId, [placeId+tagId]',
|
||||
|
||||
// ─── Shared: Global Tags (appId: 'tags') ───
|
||||
globalTags: 'id, name, groupId',
|
||||
tagGroups: 'id',
|
||||
|
|
@ -239,6 +244,7 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
|
|||
habits: ['habits', 'habitLogs'],
|
||||
notes: ['notes', 'noteTags'],
|
||||
finance: ['transactions', 'financeCategories', 'budgets'],
|
||||
places: ['places', 'locationLogs', 'placeTags'],
|
||||
tags: ['globalTags', 'tagGroups'],
|
||||
links: ['manaLinks'],
|
||||
};
|
||||
|
|
|
|||
481
apps/manacore/apps/web/src/lib/modules/places/ListView.svelte
Normal file
481
apps/manacore/apps/web/src/lib/modules/places/ListView.svelte
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
<!--
|
||||
Places — Workbench ListView
|
||||
Location tracking toggle, place list with search + quick create.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalPlace } from './types';
|
||||
import { placesStore } from './stores/places.svelte';
|
||||
import { trackingStore } from './stores/tracking.svelte';
|
||||
import { Star, MapPin, Plus } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
|
||||
import type { TagDragData } from '@manacore/shared-ui/dnd';
|
||||
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
const tagsQuery = useAllTags();
|
||||
let allTags = $derived(tagsQuery.value ?? []);
|
||||
|
||||
function handleTagDrop(placeId: string, tagData: TagDragData) {
|
||||
const place = places.find((p) => p.id === placeId);
|
||||
if (!place) return;
|
||||
const current = place.tagIds ?? [];
|
||||
if (!current.includes(tagData.id)) {
|
||||
placesStore.updateTagIds(placeId, [...current, tagData.id]);
|
||||
}
|
||||
}
|
||||
|
||||
let places = $state<LocalPlace[]>([]);
|
||||
let search = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
return db
|
||||
.table<LocalPlace>('places')
|
||||
.toArray()
|
||||
.then((all) => all.filter((p) => !p.deletedAt && !p.isArchived));
|
||||
}).subscribe((val) => {
|
||||
places = val ?? [];
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
const filtered = $derived(() => {
|
||||
if (!search.trim()) return places;
|
||||
const q = search.toLowerCase();
|
||||
return places.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.address?.toLowerCase().includes(q) ||
|
||||
p.category?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
home: 'Zuhause',
|
||||
work: 'Arbeit',
|
||||
food: 'Essen',
|
||||
shopping: 'Einkauf',
|
||||
transit: 'Transit',
|
||||
leisure: 'Freizeit',
|
||||
other: 'Sonstiges',
|
||||
};
|
||||
|
||||
// Quick create
|
||||
let newName = $state('');
|
||||
|
||||
async function createPlace() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
|
||||
// Use current position if tracking, otherwise default to 0,0
|
||||
const lat = trackingStore.currentPosition?.coords.latitude ?? 0;
|
||||
const lng = trackingStore.currentPosition?.coords.longitude ?? 0;
|
||||
|
||||
const place = await placesStore.createPlace({ name, latitude: lat, longitude: lng });
|
||||
newName = '';
|
||||
navigate('detail', { placeId: place.id });
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createPlace();
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(placeId: string) {
|
||||
const ids = filtered().map((p) => p.id);
|
||||
navigate('detail', { placeId, _siblingIds: ids, _siblingKey: 'placeId' });
|
||||
}
|
||||
|
||||
function formatCoords(lat: number, lng: number): string {
|
||||
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="places-list-view">
|
||||
<!-- Tracking Section -->
|
||||
<div class="tracking-section">
|
||||
<div class="tracking-row">
|
||||
<button
|
||||
class="tracking-toggle"
|
||||
class:active={trackingStore.isTracking}
|
||||
onclick={() =>
|
||||
trackingStore.isTracking ? trackingStore.stopTracking() : trackingStore.startTracking()}
|
||||
>
|
||||
<span class="tracking-dot" class:pulse={trackingStore.isTracking}></span>
|
||||
{trackingStore.isTracking ? 'Tracking aktiv' : 'Tracking starten'}
|
||||
</button>
|
||||
{#if trackingStore.currentPosition}
|
||||
<span class="tracking-coords">
|
||||
{formatCoords(
|
||||
trackingStore.currentPosition.coords.latitude,
|
||||
trackingStore.currentPosition.coords.longitude
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if trackingStore.error}
|
||||
<span class="tracking-error">{trackingStore.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-row">
|
||||
<input class="search-input" type="text" placeholder="Orte suchen..." bind:value={search} />
|
||||
</div>
|
||||
|
||||
<!-- Quick Create -->
|
||||
<div class="create-row">
|
||||
<input
|
||||
class="create-input"
|
||||
type="text"
|
||||
placeholder="Neuen Ort erstellen..."
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
<button class="create-btn" onclick={createPlace} disabled={!newName.trim()}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Place List -->
|
||||
<div class="place-list">
|
||||
{#each filtered() as place (place.id)}
|
||||
{@const tags = getTagsByIds(allTags, place.tagIds ?? [])}
|
||||
<button
|
||||
class="place-item"
|
||||
onclick={() => openDetail(place.id)}
|
||||
use:dropTarget={{
|
||||
accepts: ['tag'],
|
||||
onDrop: (payload) => handleTagDrop(place.id, payload.data as unknown as TagDragData),
|
||||
}}
|
||||
use:dragSource={{
|
||||
type: 'place',
|
||||
data: () => ({ ...place }),
|
||||
}}
|
||||
>
|
||||
<div class="place-icon">
|
||||
<MapPin size={16} />
|
||||
</div>
|
||||
<div class="place-info">
|
||||
<span class="place-name">
|
||||
{place.name}
|
||||
{#if place.isFavorite}
|
||||
<Star size={10} weight="fill" class="fav-star" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="place-meta">
|
||||
{#if place.category}
|
||||
<span class="category-badge">{CATEGORY_LABELS[place.category] ?? place.category}</span
|
||||
>
|
||||
{/if}
|
||||
{#if place.latitude && place.longitude}
|
||||
<span class="coords">{formatCoords(place.latitude, place.longitude)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if tags.length > 0}
|
||||
<div class="place-tags">
|
||||
{#each tags as tag (tag.id)}
|
||||
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="place-stats">
|
||||
{#if (place.visitCount ?? 0) > 0}
|
||||
<span class="visit-count">{place.visitCount}x</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filtered().length === 0 && !search}
|
||||
<div class="empty">
|
||||
<p>Noch keine Orte gespeichert.</p>
|
||||
<p class="empty-hint">Starte das Tracking oder erstelle einen Ort manuell.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filtered().length === 0 && search}
|
||||
<div class="empty">
|
||||
<p>Keine Orte gefunden.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.places-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Tracking ──────────────────────────────── */
|
||||
.tracking-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.tracking-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tracking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.15));
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tracking-toggle.active {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
border-color: rgba(14, 165, 233, 0.3);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.tracking-toggle:hover {
|
||||
border-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.tracking-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tracking-dot.pulse {
|
||||
background: #0ea5e9;
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tracking-coords {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tracking-error {
|
||||
font-size: 0.6875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────── */
|
||||
.search-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ── Quick Create ─────────────────────────── */
|
||||
.create-row {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.create-input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.create-input:focus {
|
||||
border-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.create-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.create-btn:hover:not(:disabled) {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
border-color: #0ea5e9;
|
||||
color: #0ea5e9;
|
||||
}
|
||||
|
||||
.create-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Place List ───────────────────────────── */
|
||||
.place-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.place-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.place-item:hover {
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
.place-icon {
|
||||
color: #0ea5e9;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.place-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.place-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.place-name :global(.fav-star) {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.place-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0ea5e9;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coords {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.place-tags {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.place-stats {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visit-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Empty ────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.8125rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
apps/manacore/apps/web/src/lib/modules/places/collections.ts
Normal file
40
apps/manacore/apps/web/src/lib/modules/places/collections.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Places module — collection accessors and guest seed data.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalPlace, LocalLocationLog } from './types';
|
||||
|
||||
// ─── Collection Accessors ──────────────────────────────────
|
||||
|
||||
export const placeTable = db.table<LocalPlace>('places');
|
||||
export const locationLogTable = db.table<LocalLocationLog>('locationLogs');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
|
||||
export const PLACES_GUEST_SEED = {
|
||||
places: [
|
||||
{
|
||||
id: 'guest-place-home',
|
||||
name: 'Zuhause',
|
||||
latitude: 47.6603,
|
||||
longitude: 9.1751,
|
||||
category: 'home' as const,
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
visitCount: 12,
|
||||
lastVisitedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'guest-place-work',
|
||||
name: 'Buero',
|
||||
latitude: 47.6588,
|
||||
longitude: 9.1753,
|
||||
category: 'work' as const,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visitCount: 8,
|
||||
},
|
||||
] satisfies LocalPlace[],
|
||||
locationLogs: [] satisfies LocalLocationLog[],
|
||||
};
|
||||
19
apps/manacore/apps/web/src/lib/modules/places/index.ts
Normal file
19
apps/manacore/apps/web/src/lib/modules/places/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Places module — barrel exports.
|
||||
*/
|
||||
|
||||
export { placesStore } from './stores/places.svelte';
|
||||
export { trackingStore } from './stores/tracking.svelte';
|
||||
export {
|
||||
useAllPlaces,
|
||||
useLocationLogs,
|
||||
toPlace,
|
||||
toLocationLog,
|
||||
searchPlaces,
|
||||
filterFavorites,
|
||||
filterActive,
|
||||
getDistanceKm,
|
||||
findNearestPlace,
|
||||
} from './queries';
|
||||
export { placeTable, locationLogTable, PLACES_GUEST_SEED } from './collections';
|
||||
export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types';
|
||||
115
apps/manacore/apps/web/src/lib/modules/places/queries.ts
Normal file
115
apps/manacore/apps/web/src/lib/modules/places/queries.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Reactive queries & pure helpers for Places — uses Dexie liveQuery on the unified DB.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalPlace, LocalLocationLog, Place, LocationLog } from './types';
|
||||
|
||||
// ─── Type Converters ─────────────────────────────────────
|
||||
|
||||
export function toPlace(local: LocalPlace): Place {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description || null,
|
||||
latitude: local.latitude,
|
||||
longitude: local.longitude,
|
||||
address: local.address || null,
|
||||
category: local.category ?? 'other',
|
||||
isFavorite: local.isFavorite ?? false,
|
||||
isArchived: local.isArchived ?? false,
|
||||
visitCount: local.visitCount ?? 0,
|
||||
lastVisitedAt: local.lastVisitedAt || null,
|
||||
tagIds: local.tagIds ?? [],
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toLocationLog(local: LocalLocationLog): LocationLog {
|
||||
return {
|
||||
id: local.id,
|
||||
latitude: local.latitude,
|
||||
longitude: local.longitude,
|
||||
accuracy: local.accuracy ?? null,
|
||||
altitude: local.altitude ?? null,
|
||||
speed: local.speed ?? null,
|
||||
heading: local.heading ?? null,
|
||||
timestamp: local.timestamp,
|
||||
placeId: local.placeId || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ────────────────────────────────────────
|
||||
|
||||
export function useAllPlaces() {
|
||||
return liveQuery(async () => {
|
||||
const locals = await db.table<LocalPlace>('places').toArray();
|
||||
return locals.filter((p) => !p.deletedAt).map(toPlace);
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocationLogs(placeId?: string) {
|
||||
return liveQuery(async () => {
|
||||
let query = db.table<LocalLocationLog>('locationLogs').orderBy('timestamp').reverse();
|
||||
const locals = await query.toArray();
|
||||
const filtered = placeId ? locals.filter((l) => l.placeId === placeId) : locals;
|
||||
return filtered.map(toLocationLog);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Pure Filter / Search ────────────────────────────────
|
||||
|
||||
export function searchPlaces(places: Place[], query: string): Place[] {
|
||||
if (!query.trim()) return places;
|
||||
const q = query.toLowerCase().trim();
|
||||
return places.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.address?.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterFavorites(places: Place[]): Place[] {
|
||||
return places.filter((p) => p.isFavorite);
|
||||
}
|
||||
|
||||
export function filterActive(places: Place[]): Place[] {
|
||||
return places.filter((p) => !p.isArchived);
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance between two coordinates in kilometers.
|
||||
*/
|
||||
export function getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest known place within a given radius (km).
|
||||
*/
|
||||
export function findNearestPlace(
|
||||
places: Place[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
radiusKm = 0.1
|
||||
): Place | null {
|
||||
let nearest: Place | null = null;
|
||||
let minDist = radiusKm;
|
||||
for (const p of places) {
|
||||
const d = getDistanceKm(lat, lng, p.latitude, p.longitude);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Places Store — Mutation-Only
|
||||
*
|
||||
* All reads are handled by liveQuery hooks in queries.ts.
|
||||
* This store only exposes mutations that write to IndexedDB.
|
||||
*/
|
||||
|
||||
import { placeTable } from '../collections';
|
||||
import { toPlace } from '../queries';
|
||||
import type { LocalPlace, Place, PlaceCategory } from '../types';
|
||||
|
||||
export const placesStore = {
|
||||
async createPlace(data: {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
description?: string;
|
||||
address?: string;
|
||||
category?: PlaceCategory;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const newLocal: LocalPlace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
description: data.description,
|
||||
address: data.address,
|
||||
category: data.category ?? 'other',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visitCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await placeTable.add(newLocal);
|
||||
return toPlace(newLocal);
|
||||
},
|
||||
|
||||
async updatePlace(id: string, data: Partial<Place> & Record<string, unknown>) {
|
||||
const updateData: Partial<LocalPlace> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description ?? undefined;
|
||||
if (data.latitude !== undefined) updateData.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updateData.longitude = data.longitude;
|
||||
if (data.address !== undefined) updateData.address = data.address ?? undefined;
|
||||
if (data.category !== undefined) updateData.category = data.category;
|
||||
if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite;
|
||||
if (data.isArchived !== undefined) updateData.isArchived = data.isArchived;
|
||||
|
||||
await placeTable.update(id, {
|
||||
...updateData,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deletePlace(id: string) {
|
||||
await placeTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string) {
|
||||
const local = await placeTable.get(id);
|
||||
if (!local) return;
|
||||
|
||||
await placeTable.update(id, {
|
||||
isFavorite: !local.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async updateTagIds(id: string, tagIds: string[]) {
|
||||
await placeTable.update(id, {
|
||||
tagIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async recordVisit(id: string) {
|
||||
const local = await placeTable.get(id);
|
||||
if (!local) return;
|
||||
|
||||
await placeTable.update(id, {
|
||||
visitCount: (local.visitCount ?? 0) + 1,
|
||||
lastVisitedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Tracking Store — Browser Geolocation API wrapper with Svelte 5 runes.
|
||||
*
|
||||
* Tracks the user's position via watchPosition and periodically logs
|
||||
* entries to IndexedDB. Also detects proximity to known places.
|
||||
*/
|
||||
|
||||
import { locationLogTable, placeTable } from '../collections';
|
||||
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
|
||||
import type { LocalLocationLog, LocalPlace } from '../types';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────
|
||||
|
||||
let isTracking = $state(false);
|
||||
let currentPosition = $state<GeolocationPosition | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let permissionState = $state<string>('unknown');
|
||||
|
||||
let _watchId: number | null = null;
|
||||
let _lastLogTime = 0;
|
||||
|
||||
/** Minimum seconds between log entries (default: 5 minutes). */
|
||||
const LOG_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// ─── Permission Check ───────────────────────────────────
|
||||
|
||||
async function checkPermission(): Promise<string> {
|
||||
try {
|
||||
const result = await navigator.permissions.query({ name: 'geolocation' });
|
||||
permissionState = result.state;
|
||||
result.addEventListener('change', () => {
|
||||
permissionState = result.state;
|
||||
});
|
||||
return result.state;
|
||||
} catch {
|
||||
permissionState = 'unknown';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core Tracking ──────────────────────────────────────
|
||||
|
||||
function startTracking() {
|
||||
if (isTracking || !navigator.geolocation) return;
|
||||
|
||||
error = null;
|
||||
isTracking = true;
|
||||
|
||||
_watchId = navigator.geolocation.watchPosition(
|
||||
async (pos) => {
|
||||
currentPosition = pos;
|
||||
error = null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - _lastLogTime >= LOG_INTERVAL_MS) {
|
||||
_lastLogTime = now;
|
||||
await logPosition(pos);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
error = err.message;
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000,
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
checkPermission();
|
||||
}
|
||||
|
||||
function stopTracking() {
|
||||
if (_watchId !== null) {
|
||||
navigator.geolocation.clearWatch(_watchId);
|
||||
_watchId = null;
|
||||
}
|
||||
isTracking = false;
|
||||
}
|
||||
|
||||
async function getCurrentPosition(): Promise<GeolocationPosition | null> {
|
||||
if (!navigator.geolocation) {
|
||||
error = 'Geolocation wird nicht unterstuetzt';
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
currentPosition = pos;
|
||||
error = null;
|
||||
resolve(pos);
|
||||
},
|
||||
(err) => {
|
||||
error = err.message;
|
||||
resolve(null);
|
||||
},
|
||||
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 15_000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Log to IndexedDB ───────────────────────────────────
|
||||
|
||||
async function logPosition(pos: GeolocationPosition) {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
|
||||
// Check proximity to known places
|
||||
const allLocals = await placeTable.toArray();
|
||||
const places = allLocals.filter((p) => !p.deletedAt).map(toPlace);
|
||||
const nearest = findNearestPlace(places, lat, lng);
|
||||
|
||||
const log: LocalLocationLog = {
|
||||
id: crypto.randomUUID(),
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
accuracy: pos.coords.accuracy ?? undefined,
|
||||
altitude: pos.coords.altitude ?? undefined,
|
||||
speed: pos.coords.speed ?? undefined,
|
||||
heading: pos.coords.heading ?? undefined,
|
||||
timestamp: new Date(pos.timestamp).toISOString(),
|
||||
placeId: nearest?.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await locationLogTable.add(log);
|
||||
|
||||
// Update visit count on the matched place
|
||||
if (nearest) {
|
||||
const local = await placeTable.get(nearest.id);
|
||||
if (local) {
|
||||
await placeTable.update(nearest.id, {
|
||||
visitCount: (local.visitCount ?? 0) + 1,
|
||||
lastVisitedAt: log.timestamp,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Force-Log (ignores interval) ───────────────────────
|
||||
|
||||
async function logNow() {
|
||||
if (!currentPosition) {
|
||||
const pos = await getCurrentPosition();
|
||||
if (pos) {
|
||||
_lastLogTime = Date.now();
|
||||
await logPosition(pos);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastLogTime = Date.now();
|
||||
await logPosition(currentPosition);
|
||||
}
|
||||
|
||||
// ─── Exports ────────────────────────────────────────────
|
||||
|
||||
export const trackingStore = {
|
||||
get isTracking() {
|
||||
return isTracking;
|
||||
},
|
||||
get currentPosition() {
|
||||
return currentPosition;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get permissionState() {
|
||||
return permissionState;
|
||||
},
|
||||
startTracking,
|
||||
stopTracking,
|
||||
getCurrentPosition,
|
||||
checkPermission,
|
||||
logNow,
|
||||
};
|
||||
63
apps/manacore/apps/web/src/lib/modules/places/types.ts
Normal file
63
apps/manacore/apps/web/src/lib/modules/places/types.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Places module types for the unified app.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@manacore/local-store';
|
||||
|
||||
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
|
||||
|
||||
export interface LocalPlace extends BaseRecord {
|
||||
name: string;
|
||||
description?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
category?: PlaceCategory;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
visitCount?: number;
|
||||
lastVisitedAt?: string;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
export interface LocalLocationLog extends BaseRecord {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
heading?: number;
|
||||
timestamp: string;
|
||||
placeId?: string;
|
||||
}
|
||||
|
||||
// ─── Shared Place Type ──────────────────────────────────
|
||||
|
||||
export interface Place {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string | null;
|
||||
category: PlaceCategory;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
visitCount: number;
|
||||
lastVisitedAt: string | null;
|
||||
tagIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LocationLog {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
speed: number | null;
|
||||
heading: number | null;
|
||||
timestamp: string;
|
||||
placeId: string | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
<!--
|
||||
Places — DetailView (inline editable overlay)
|
||||
All fields are always editable. Changes auto-save on blur.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { placesStore } from '../stores/places.svelte';
|
||||
import { Trash, Star, MapPin, X } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
|
||||
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
|
||||
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let placeId = $derived(params.placeId as string);
|
||||
|
||||
let place = $state<LocalPlace | null>(null);
|
||||
let logs = $state<LocalLocationLog[]>([]);
|
||||
let confirmDelete = $state(false);
|
||||
let focused = $state(false);
|
||||
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editAddress = $state('');
|
||||
let editCategory = $state<PlaceCategory>('other');
|
||||
let editLatitude = $state('');
|
||||
let editLongitude = $state('');
|
||||
|
||||
const tagsQuery = useAllTags();
|
||||
let allTags = $derived(tagsQuery.value ?? []);
|
||||
let placeTags = $derived(getTagsByIds(allTags, place?.tagIds ?? []));
|
||||
|
||||
const CATEGORIES: { value: PlaceCategory; label: string }[] = [
|
||||
{ value: 'home', label: 'Zuhause' },
|
||||
{ value: 'work', label: 'Arbeit' },
|
||||
{ value: 'food', label: 'Essen' },
|
||||
{ value: 'shopping', label: 'Einkauf' },
|
||||
{ value: 'transit', label: 'Transit' },
|
||||
{ value: 'leisure', label: 'Freizeit' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
async function removeTag(tagId: string) {
|
||||
const current = place?.tagIds ?? [];
|
||||
await placesStore.updateTagIds(
|
||||
placeId,
|
||||
current.filter((id) => id !== tagId)
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
placeId; // track
|
||||
confirmDelete = false;
|
||||
focused = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() => db.table<LocalPlace>('places').get(placeId)).subscribe((val) => {
|
||||
place = val ?? null;
|
||||
if (val && !focused) syncFields(val);
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() =>
|
||||
db
|
||||
.table<LocalLocationLog>('locationLogs')
|
||||
.where('placeId')
|
||||
.equals(placeId)
|
||||
.reverse()
|
||||
.sortBy('timestamp')
|
||||
).subscribe((val) => {
|
||||
logs = (val ?? []).slice(0, 20);
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
function syncFields(p: LocalPlace) {
|
||||
editName = p.name ?? '';
|
||||
editDescription = p.description ?? '';
|
||||
editAddress = p.address ?? '';
|
||||
editCategory = p.category ?? 'other';
|
||||
editLatitude = p.latitude?.toString() ?? '';
|
||||
editLongitude = p.longitude?.toString() ?? '';
|
||||
}
|
||||
|
||||
async function saveField() {
|
||||
focused = false;
|
||||
const lat = parseFloat(editLatitude);
|
||||
const lng = parseFloat(editLongitude);
|
||||
await placesStore.updatePlace(placeId, {
|
||||
name: editName.trim() || 'Unbenannt',
|
||||
description: editDescription.trim() || null,
|
||||
address: editAddress.trim() || null,
|
||||
category: editCategory,
|
||||
latitude: isNaN(lat) ? 0 : lat,
|
||||
longitude: isNaN(lng) ? 0 : lng,
|
||||
} as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function onCategoryChange(e: Event) {
|
||||
editCategory = (e.target as HTMLSelectElement).value as PlaceCategory;
|
||||
await saveField();
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
await placesStore.toggleFavorite(placeId);
|
||||
}
|
||||
|
||||
async function deletePlace() {
|
||||
await placesStore.deletePlace(placeId);
|
||||
goBack();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
let mapUrl = $derived.by(() => {
|
||||
if (!place || !place.latitude || !place.longitude) return '';
|
||||
const lat = place.latitude;
|
||||
const lng = place.longitude;
|
||||
const bbox = `${lng - 0.005},${lat - 0.003},${lng + 0.005},${lat + 0.003}`;
|
||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !place}
|
||||
<p class="empty">Ort nicht gefunden</p>
|
||||
{:else}
|
||||
<!-- Header -->
|
||||
<div class="profile-header">
|
||||
<div class="place-avatar">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div class="name-fields">
|
||||
<input
|
||||
class="name-input"
|
||||
bind:value={editName}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Name"
|
||||
/>
|
||||
</div>
|
||||
<button class="fav-btn" class:active={place.isFavorite} onclick={toggleFavorite}>
|
||||
<Star size={18} weight={place.isFavorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Map Preview -->
|
||||
{#if mapUrl}
|
||||
<div class="map-container">
|
||||
<iframe
|
||||
title="Kartenvorschau"
|
||||
src={mapUrl}
|
||||
width="100%"
|
||||
height="160"
|
||||
frameborder="0"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Fields -->
|
||||
<div class="fields">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Kategorie</span>
|
||||
<select class="field-select" value={editCategory} onchange={onCategoryChange}>
|
||||
{#each CATEGORIES as cat}
|
||||
<option value={cat.value}>{cat.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="field-label">Adresse</span>
|
||||
<input
|
||||
class="field-input"
|
||||
bind:value={editAddress}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Adresse eingeben..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="field-label">Koordinaten</span>
|
||||
<div class="coords-row">
|
||||
<input
|
||||
class="field-input small"
|
||||
bind:value={editLatitude}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Lat"
|
||||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<input
|
||||
class="field-input small"
|
||||
bind:value={editLongitude}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Lng"
|
||||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="field-label">Beschreibung</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editDescription}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Notizen zum Ort..."
|
||||
rows={2}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if placeTags.length > 0}
|
||||
<div class="section">
|
||||
<span class="section-label">Tags</span>
|
||||
<div class="tags-list">
|
||||
{#each placeTags as tag (tag.id)}
|
||||
<button
|
||||
class="tag-pill"
|
||||
style="--tag-color: {tag.color}"
|
||||
onclick={() => removeTag(tag.id)}
|
||||
>
|
||||
<span class="tag-dot" style="background: {tag.color}"></span>
|
||||
{tag.name}
|
||||
<X size={10} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Links -->
|
||||
<LinkedItems recordRef={{ app: 'places', collection: 'places', id: placeId }} {navigate} />
|
||||
|
||||
<!-- Visit Log -->
|
||||
{#if logs.length > 0}
|
||||
<div class="section">
|
||||
<span class="section-label">Letzte Besuche</span>
|
||||
<div class="log-list">
|
||||
{#each logs as log (log.id)}
|
||||
<div class="log-row">
|
||||
<span class="log-time">{formatDate(log.timestamp)}</span>
|
||||
{#if log.accuracy}
|
||||
<span class="log-accuracy">±{Math.round(log.accuracy)}m</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats & Meta -->
|
||||
<div class="meta">
|
||||
{#if (place.visitCount ?? 0) > 0}
|
||||
<span>Besuche: {place.visitCount}</span>
|
||||
{/if}
|
||||
{#if place.lastVisitedAt}
|
||||
<span>Letzter Besuch: {formatDate(place.lastVisitedAt)}</span>
|
||||
{/if}
|
||||
{#if place.createdAt}
|
||||
<span>Erstellt: {new Date(place.createdAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
{#if place.updatedAt}
|
||||
<span>Bearbeitet: {new Date(place.updatedAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="danger-zone">
|
||||
{#if confirmDelete}
|
||||
<p class="confirm-text">Ort wirklich loeschen?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn danger" onclick={deletePlace}>Loeschen</button>
|
||||
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
|
||||
<Trash size={14} /> Loeschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.place-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.name-fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name-input {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
.name-input:focus {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.name-input::placeholder {
|
||||
color: #c0bfba;
|
||||
font-weight: 400;
|
||||
}
|
||||
:global(.dark) .name-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .name-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .name-input::placeholder {
|
||||
color: #4b5563;
|
||||
}
|
||||
.fav-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
padding: 0.25rem;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fav-btn.active {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.fav-btn:hover {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.map-container {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
.map-container iframe {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.field-input,
|
||||
.field-textarea,
|
||||
.field-select {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input:hover,
|
||||
.field-input:focus,
|
||||
.field-textarea:hover,
|
||||
.field-textarea:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.field-input::placeholder,
|
||||
.field-textarea::placeholder {
|
||||
color: #c0bfba;
|
||||
}
|
||||
.field-input.small {
|
||||
flex: 1;
|
||||
}
|
||||
.field-select {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
.field-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
:global(.dark) .field-input,
|
||||
:global(.dark) .field-textarea,
|
||||
:global(.dark) .field-select {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .field-input:hover,
|
||||
:global(.dark) .field-input:focus,
|
||||
:global(.dark) .field-textarea:hover,
|
||||
:global(.dark) .field-textarea:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .field-input::placeholder,
|
||||
:global(.dark) .field-textarea::placeholder {
|
||||
color: #4b5563;
|
||||
}
|
||||
:global(.dark) .field-select {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.coords-row {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--tag-color) 12%, transparent);
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
background: color-mix(in srgb, var(--tag-color) 20%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
:global(.dark) .tag-pill {
|
||||
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .tag-pill:hover {
|
||||
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
.tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Visit Log */
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.log-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.log-time {
|
||||
color: var(--color-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-accuracy {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* Meta */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .meta {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Delete */
|
||||
.danger-zone {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #374151;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger-subtle {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -30,6 +30,7 @@ const SPLIT_APP_ID_LIST = [
|
|||
'calc',
|
||||
'moodlit',
|
||||
'memoro',
|
||||
'places',
|
||||
'playground',
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ export const APP_ICONS = {
|
|||
finance: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fn)"/><circle cx="50" cy="50" r="22" stroke="white" stroke-width="4" fill="none"/><path d="M50 34v32M42 42c0-4 3.5-6 8-6s8 2 8 6-3.5 5-8 5-8 2-8 6 3.5 6 8 6 8-2 8-6" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
|
||||
),
|
||||
places: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="plc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0ea5e9"/><stop offset="100%" style="stop-color:#0284c7"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#plc)"/><path d="M50 20c-14 0-25 11-25 25 0 20 25 38 25 38s25-18 25-38c0-14-11-25-25-25zm0 34a9 9 0 1 1 0-18 9 9 0 0 1 0 18z" fill="white"/></svg>`
|
||||
),
|
||||
arcade: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'founder',
|
||||
},
|
||||
{
|
||||
id: 'places',
|
||||
name: 'Places',
|
||||
description: {
|
||||
de: 'Standort-Tracking',
|
||||
en: 'Location Tracking',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Tracke deinen Standort, erstelle Orte und sieh deine Bewegungshistorie.',
|
||||
en: 'Track your location, create places, and view your movement history.',
|
||||
},
|
||||
icon: APP_ICONS.places,
|
||||
color: '#0ea5e9',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'founder',
|
||||
},
|
||||
{
|
||||
id: 'arcade',
|
||||
name: 'Arcade',
|
||||
|
|
@ -763,6 +780,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
|||
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
|
||||
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
|
||||
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
|
||||
places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },
|
||||
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },
|
||||
news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' },
|
||||
mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' },
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ export type DragType =
|
|||
| 'contact'
|
||||
| 'habit'
|
||||
| 'note'
|
||||
| 'transaction';
|
||||
| 'transaction'
|
||||
| 'place';
|
||||
|
||||
export interface DragPayload<T = Record<string, unknown>> {
|
||||
type: DragType;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function setSecurityHeaders(response: Response, options: SecurityHeadersO
|
|||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
|
||||
|
||||
// Content Security Policy
|
||||
const cspDirectives = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue