mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
feat(wetter): improve location management with save/remove/default
Rework LocationPicker with three actions: - "+" button opens search that saves results as persistent locations - Gear button opens manage panel to set default (★) or remove cities - Saved locations appear as chips; default location auto-loads on open Search results show "Speichern" label and auto-save on click. Already saved locations show "Gespeichert" instead. Default location marked with a blue dot in chips and a star in the manage panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1add202c56
commit
ef91b61e20
3 changed files with 247 additions and 61 deletions
|
|
@ -35,6 +35,14 @@
|
|||
await locationsStore.addLocation(name, lat, lon);
|
||||
}
|
||||
|
||||
async function removeLocation(id: string) {
|
||||
await locationsStore.removeLocation(id);
|
||||
}
|
||||
|
||||
async function setDefaultLocation(id: string) {
|
||||
await locationsStore.setDefault(id);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (weatherStore.weatherData) return;
|
||||
|
||||
|
|
@ -66,6 +74,8 @@
|
|||
{selectedLon}
|
||||
onSelect={selectLocation}
|
||||
onSave={saveLocation}
|
||||
onRemove={removeLocation}
|
||||
onSetDefault={setDefaultLocation}
|
||||
/>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<!--
|
||||
Location picker — dropdown of saved locations + GPS + search.
|
||||
Location picker — saved location chips with delete, GPS button,
|
||||
search with instant-save, and a manage mode to reorder/remove.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { WeatherLocation, GeocodingResult } from '../types';
|
||||
|
|
@ -11,14 +12,18 @@
|
|||
selectedLon: number | null;
|
||||
onSelect: (lat: number, lon: number, name: string) => void;
|
||||
onSave: (name: string, lat: number, lon: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onSetDefault: (id: string) => void;
|
||||
}
|
||||
|
||||
let { locations, selectedLat, selectedLon, onSelect, onSave }: Props = $props();
|
||||
let { locations, selectedLat, selectedLon, onSelect, onSave, onRemove, onSetDefault }: Props =
|
||||
$props();
|
||||
|
||||
let searching = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<GeocodingResult[]>([]);
|
||||
let showSearch = $state(false);
|
||||
let showManage = $state(false);
|
||||
let locating = $state(false);
|
||||
|
||||
async function onSearch(e: Event) {
|
||||
|
|
@ -42,6 +47,12 @@
|
|||
searchResults = [];
|
||||
}
|
||||
|
||||
function saveAndSelect(result: GeocodingResult) {
|
||||
const name = result.admin1 ? `${result.name}, ${result.admin1}` : result.name;
|
||||
onSave(name, result.lat, result.lon);
|
||||
selectSearchResult(result);
|
||||
}
|
||||
|
||||
async function useGps() {
|
||||
if (!navigator.geolocation) return;
|
||||
locating = true;
|
||||
|
|
@ -61,9 +72,16 @@
|
|||
if (selectedLat == null || selectedLon == null) return false;
|
||||
return Math.abs(loc.lat - selectedLat) < 0.01 && Math.abs(loc.lon - selectedLon) < 0.01;
|
||||
}
|
||||
|
||||
function alreadySaved(result: GeocodingResult): boolean {
|
||||
return locations.some(
|
||||
(l) => Math.abs(l.lat - result.lat) < 0.01 && Math.abs(l.lon - result.lon) < 0.01
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="location-picker">
|
||||
<!-- Saved location chips -->
|
||||
<div class="picker-row">
|
||||
{#if locations.length > 0}
|
||||
<div class="location-chips">
|
||||
|
|
@ -71,68 +89,127 @@
|
|||
<button
|
||||
class="loc-chip"
|
||||
class:active={isSelected(loc)}
|
||||
class:is-default={loc.isDefault}
|
||||
onclick={() => onSelect(loc.lat, loc.lon, loc.name)}
|
||||
>
|
||||
{#if loc.isDefault}<span class="default-dot"></span>{/if}
|
||||
{loc.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="no-locations">Keine Orte gespeichert</span>
|
||||
{/if}
|
||||
<div class="picker-actions">
|
||||
<button class="action-btn" onclick={useGps} disabled={locating} title="Aktueller Standort">
|
||||
{locating ? '...' : '📍'}
|
||||
</button>
|
||||
<button class="action-btn" onclick={() => (showSearch = !showSearch)} title="Ort suchen">
|
||||
🔍
|
||||
<button
|
||||
class="action-btn"
|
||||
class:active={showSearch}
|
||||
onclick={() => {
|
||||
showSearch = !showSearch;
|
||||
showManage = false;
|
||||
}}
|
||||
title="Stadt hinzufuegen"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{#if locations.length > 0}
|
||||
<button
|
||||
class="action-btn manage-btn"
|
||||
class:active={showManage}
|
||||
onclick={() => {
|
||||
showManage = !showManage;
|
||||
showSearch = false;
|
||||
}}
|
||||
title="Orte verwalten"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search panel -->
|
||||
{#if showSearch}
|
||||
<form class="search-form" onsubmit={onSearch}>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Stadt suchen..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<button class="search-btn" type="submit" disabled={searching}>
|
||||
{searching ? '...' : 'Suchen'}
|
||||
</button>
|
||||
</form>
|
||||
<div class="panel">
|
||||
<form class="search-form" onsubmit={onSearch}>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Stadt suchen und speichern..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<button class="search-btn" type="submit" disabled={searching}>
|
||||
{searching ? '...' : 'Suchen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if searchResults.length > 0}
|
||||
<div class="search-results">
|
||||
{#each searchResults as result}
|
||||
<div
|
||||
class="result-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => selectSearchResult(result)}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') selectSearchResult(result);
|
||||
}}
|
||||
>
|
||||
<span class="result-name">{result.name}</span>
|
||||
<span class="result-detail">
|
||||
{result.admin1 ? `${result.admin1}, ` : ''}{result.country}
|
||||
</span>
|
||||
<button
|
||||
class="save-btn"
|
||||
title="Ort speichern"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const name = result.admin1 ? `${result.name}, ${result.admin1}` : result.name;
|
||||
onSave(name, result.lat, result.lon);
|
||||
selectSearchResult(result);
|
||||
{#if searchResults.length > 0}
|
||||
<div class="search-results">
|
||||
{#each searchResults as result}
|
||||
{@const saved = alreadySaved(result)}
|
||||
<div
|
||||
class="result-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => {
|
||||
if (!saved) saveAndSelect(result);
|
||||
else selectSearchResult(result);
|
||||
}}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (!saved) saveAndSelect(result);
|
||||
else selectSearchResult(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
+
|
||||
<span class="result-name">{result.name}</span>
|
||||
<span class="result-detail">
|
||||
{result.admin1 ? `${result.admin1}, ` : ''}{result.country}
|
||||
</span>
|
||||
{#if saved}
|
||||
<span class="already-saved">Gespeichert</span>
|
||||
{:else}
|
||||
<span class="save-label">Speichern</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Manage panel -->
|
||||
{#if showManage}
|
||||
<div class="panel manage-panel">
|
||||
<span class="panel-title">Gespeicherte Orte</span>
|
||||
{#each locations as loc (loc.id)}
|
||||
<div class="manage-row">
|
||||
<span class="manage-name">
|
||||
{#if loc.isDefault}<span class="default-star">★</span>{/if}
|
||||
{loc.name}
|
||||
</span>
|
||||
<span class="manage-coords">{loc.lat.toFixed(2)}, {loc.lon.toFixed(2)}</span>
|
||||
<div class="manage-actions">
|
||||
{#if !loc.isDefault}
|
||||
<button
|
||||
class="manage-action"
|
||||
title="Als Standard setzen"
|
||||
onclick={() => onSetDefault(loc.id)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/if}
|
||||
<button class="manage-action delete" title="Entfernen" onclick={() => onRemove(loc.id)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<p class="manage-hint">★ = Standard-Ort beim Oeffnen</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -153,7 +230,15 @@
|
|||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
.no-locations {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
flex: 1;
|
||||
}
|
||||
.loc-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -168,6 +253,13 @@
|
|||
color: #38bdf8;
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
.default-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #38bdf8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.picker-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
|
@ -184,10 +276,36 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
.action-btn:hover {
|
||||
.action-btn:hover,
|
||||
.action-btn.active {
|
||||
background: var(--card-bg-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-primary, #f3f4f6);
|
||||
}
|
||||
.manage-btn {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.06));
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -197,7 +315,7 @@
|
|||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.06));
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.04));
|
||||
color: var(--text-primary, #f3f4f6);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
|
|
@ -221,23 +339,19 @@
|
|||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.06));
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary, #f3f4f6);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.result-item:hover {
|
||||
background: var(--card-bg-hover, rgba(255, 255, 255, 0.06));
|
||||
|
|
@ -251,22 +365,74 @@
|
|||
color: var(--text-secondary, #9ca3af);
|
||||
flex: 1;
|
||||
}
|
||||
.save-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.15));
|
||||
.save-label {
|
||||
font-size: 0.7rem;
|
||||
color: #38bdf8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.already-saved {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Manage panel */
|
||||
.manage-panel {
|
||||
gap: 4px;
|
||||
}
|
||||
.manage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.manage-row:hover {
|
||||
background: var(--card-bg-hover, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
.manage-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary, #f3f4f6);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.default-star {
|
||||
color: #f59e0b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.manage-coords {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
}
|
||||
.manage-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.manage-action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1));
|
||||
background: none;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-btn:hover {
|
||||
background: var(--accent-subtle, rgba(56, 189, 248, 0.15));
|
||||
color: #38bdf8;
|
||||
.manage-action:hover {
|
||||
background: var(--card-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
color: #f59e0b;
|
||||
}
|
||||
.manage-action.delete:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
.manage-hint {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@
|
|||
await locationsStore.addLocation(name, lat, lon);
|
||||
}
|
||||
|
||||
async function removeLocation(id: string) {
|
||||
await locationsStore.removeLocation(id);
|
||||
}
|
||||
|
||||
async function setDefaultLocation(id: string) {
|
||||
await locationsStore.setDefault(id);
|
||||
}
|
||||
|
||||
// On mount: use default location, or first saved, or GPS
|
||||
onMount(() => {
|
||||
const defaultLoc = locations.find((l) => l.isDefault) ?? locations[0];
|
||||
|
|
@ -72,6 +80,8 @@
|
|||
{selectedLon}
|
||||
onSelect={selectLocation}
|
||||
onSave={saveLocation}
|
||||
onRemove={removeLocation}
|
||||
onSetDefault={setDefaultLocation}
|
||||
/>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue