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:
Till JS 2026-04-17 14:13:30 +02:00
parent 1add202c56
commit ef91b61e20
3 changed files with 247 additions and 61 deletions

View file

@ -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 -->

View file

@ -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>

View file

@ -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 -->