feat(citycorners): add PWA, i18n (DE/EN), and migrate landing to Tailwind

PWA: @vite-pwa/sveltekit with shared-pwa config, offline fallback page, service worker with standard caching preset.

i18n: svelte-i18n with DE/EN locale files, all UI strings translated, language switcher in PillNav, auth pages use shared-i18n translations.

Landing: Migrated from scoped CSS to Tailwind CSS with @astrojs/tailwind. Hero section, card grid, category filter buttons, detail page with timeline. Removed unused components (Welcome, ThemeToggle, update-locations.js).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 11:11:51 +01:00
parent 7691f66cbb
commit a2f8c32059
30 changed files with 1090 additions and 975 deletions

View file

@ -1,5 +1,8 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
site: 'https://citycorners.mana.how',
integrations: [tailwind(), sitemap()],
});

View file

@ -13,7 +13,10 @@
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^6.0.0",
"astro": "^5.16.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.0.0"
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,25 +1,66 @@
---
// This is a placeholder for the filter component.
// The actual filtering logic will be implemented on the page.
const categories = ['Sehenswürdigkeit', 'Restaurant', 'Laden'];
const categories = [
{ value: '', label: 'Alle' },
{ value: 'sehenswürdigkeit', label: 'Sehenswürdigkeiten' },
{ value: 'restaurant', label: 'Restaurants' },
{ value: 'laden', label: 'Läden' },
{ value: 'museum', label: 'Museen' },
];
---
<div class="filter-container">
<label for="category-filter">Filter by Category:</label>
<select id="category-filter">
<option value="">All</option>
{categories.map((category) => <option value={category.toLowerCase()}>{category}</option>)}
</select>
<div class="flex flex-wrap gap-2" id="category-filter">
{
categories.map((cat) => (
<button
class="filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors"
data-category={cat.value}
>
{cat.label}
</button>
))
}
</div>
<style>
.filter-container {
margin: 20px;
text-align: center;
}
#category-filter {
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('.filter-btn');
const container = document.getElementById('locations-container');
if (!container) return;
const allCards = Array.from(container.children) as HTMLElement[];
let activeCategory = '';
function updateButtons() {
buttons.forEach((btn) => {
const cat = (btn as HTMLElement).dataset.category || '';
if (cat === activeCategory) {
btn.className =
'filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors bg-primary text-white';
} else {
btn.className =
'filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors bg-white text-gray-600 hover:bg-gray-100 border border-gray-200';
}
});
}
function filterCards() {
allCards.forEach((card) => {
const cardCategory = card.dataset.category || '';
if (!activeCategory || cardCategory === activeCategory) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
activeCategory = (btn as HTMLElement).dataset.category || '';
updateButtons();
filterCards();
});
});
updateButtons();
});
</script>

View file

@ -1,25 +1,41 @@
---
interface Props {
location: {
id: number;
name: string;
category: string;
description: string;
image: string;
};
}
const { location } = Astro.props;
const categoryColors: Record<string, string> = {
Sehenswürdigkeit: 'bg-blue-100 text-blue-700',
Restaurant: 'bg-red-100 text-red-700',
Laden: 'bg-green-100 text-green-700',
Museum: 'bg-purple-100 text-purple-700',
};
---
<div class="location-card" data-category="{location.category.toLowerCase()}">
<img src={location.image} alt={location.name} />
<h3>{location.name}</h3>
<p>{location.category}</p>
<a href={`/locations/${location.id}`}>Details</a>
</div>
<style>
.location-card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
margin: 16px;
text-align: center;
}
.location-card img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
</style>
<a
href={`/locations/${location.id}`}
class="group block overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5"
data-category={location.category.toLowerCase()}
>
<img src={location.image} alt={location.name} class="h-48 w-full object-cover" loading="lazy" />
<div class="p-4">
<span
class={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${categoryColors[location.category] || 'bg-gray-100 text-gray-700'}`}
>
{location.category}
</span>
<h3 class="mt-2 text-lg font-semibold text-gray-900 group-hover:text-primary">
{location.name}
</h3>
<p class="mt-1 line-clamp-2 text-sm text-gray-600">
{location.description}
</p>
</div>
</a>

View file

@ -1,15 +0,0 @@
<button id="theme-toggle" type="button" title="Toggle Theme"> Toggle Theme </button>
<style>
#theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-text);
background-color: var(--color-background);
color: var(--color-text);
cursor: pointer;
border-radius: 4px;
}
</style>

View file

@ -1,210 +0,0 @@
---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 5.0?</h2>
<p>
From content layers to server islands, click to learn more about the new features and
improvements in Astro 5.0
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View file

@ -1,22 +1,25 @@
---
interface Props {
title?: string;
}
const { title = 'CityCorners Entdecke Konstanz' } = Astro.props;
---
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta
name="description"
content="Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden in Konstanz am Bodensee."
/>
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
<title>{title}</title>
</head>
<body>
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
<slot />
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

View file

@ -6,41 +6,33 @@ import locations from '../data/locations.json';
---
<Layout>
<main>
<h1>CityCorners Konstanz</h1>
<Filter />
<div id="locations-container" class="locations-grid">
<!-- Hero -->
<header class="bg-primary py-16 text-white">
<div class="mx-auto max-w-6xl px-6 text-center">
<h1 class="text-4xl font-bold sm:text-5xl">CityCorners</h1>
<p class="mt-3 text-lg text-blue-100">Entdecke Konstanz am Bodensee</p>
<p class="mt-1 text-sm text-blue-200">Sehenswürdigkeiten · Restaurants · Museen · Läden</p>
</div>
</header>
<!-- Main content -->
<main class="mx-auto max-w-6xl px-6 py-10">
<div class="mb-8">
<Filter />
</div>
<div id="locations-container" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{locations.map((location) => <LocationCard location={location} />)}
</div>
</main>
<!-- Footer -->
<footer class="border-t border-gray-200 bg-white py-8">
<div class="mx-auto max-w-6xl px-6 text-center text-sm text-gray-500">
<p>CityCorners Ein Stadtführer für Konstanz</p>
<p class="mt-1">
Teil des <a href="https://mana.how" class="text-primary hover:underline">Mana</a> Ökosystems
</p>
</div>
</footer>
</Layout>
<style>
.locations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const filter = document.getElementById('category-filter');
const locationsContainer = document.getElementById('locations-container');
const allLocations = Array.from(locationsContainer.children);
filter.addEventListener('change', (event) => {
const selectedCategory = event.target.value;
locationsContainer.innerHTML = ''; // Clear existing locations
const filteredLocations = allLocations.filter((locationElement) => {
if (!selectedCategory) return true; // Show all if no category is selected
return locationElement.dataset.category === selectedCategory;
});
filteredLocations.forEach((locationElement) => {
locationsContainer.appendChild(locationElement);
});
});
});
</script>

View file

@ -12,102 +12,121 @@ const { id } = Astro.params;
const location = locations.find((loc) => loc.id.toString() === id);
if (!location) {
return Astro.redirect('/404');
return Astro.redirect('/');
}
const categoryColors: Record<string, string> = {
Sehenswürdigkeit: 'bg-blue-100 text-blue-700',
Restaurant: 'bg-red-100 text-red-700',
Laden: 'bg-green-100 text-green-700',
Museum: 'bg-purple-100 text-purple-700',
};
---
<Layout>
<main class="location-detail">
<a href="/" class="back-link">&laquo; Zurück zur Übersicht</a>
<article>
<img src={location.image} alt={location.name} class="location-image" />
<h1>{location.name}</h1>
<p class="category">{location.category}</p>
<p class="description">{location.description}</p>
<Layout title={`${location.name} CityCorners`}>
<main class="mx-auto max-w-3xl px-6 py-8">
<a
href="/"
class="mb-6 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-primary"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"></path>
</svg>
Zurück zur Übersicht
</a>
{
location.timeline && (
<div class="timeline">
<h2>Historische Zeitachse</h2>
<ul>
{location.timeline.map((event) => (
<li>
<strong>{event.year}:</strong> {event.description}
</li>
))}
</ul>
<img
src={location.image}
alt={location.name}
class="mb-6 h-64 w-full rounded-xl object-cover sm:h-80"
/>
<span
class={`inline-block rounded-full px-3 py-1 text-sm font-medium ${categoryColors[location.category] || 'bg-gray-100 text-gray-700'}`}
>
{location.category}
</span>
<h1 class="mt-3 text-3xl font-bold text-gray-900">{location.name}</h1>
{
location.address && (
<p class="mt-2 flex items-center gap-1.5 text-gray-500">
<svg
class="h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
{location.address}
</p>
)
}
<p class="mt-4 text-base leading-relaxed text-gray-700">{location.description}</p>
{
location.timeline && location.timeline.length > 0 && (
<div class="mt-8">
<h2 class="mb-4 text-xl font-semibold text-gray-900">Geschichte</h2>
<div class="space-y-0">
{location.timeline.map((event: { year: string; description: string }, i: number) => (
<div class="relative flex gap-4 pb-6">
{i < location.timeline!.length - 1 && (
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-gray-200" />
)}
<div class="relative z-10 mt-1.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2 border-primary bg-white">
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<div>
<span class="font-mono text-sm font-bold text-primary">{event.year}</span>
<p class="mt-0.5 text-sm text-gray-600">{event.description}</p>
</div>
</div>
))}
</div>
)
}
</div>
)
}
<p class="address"><strong>Adresse:</strong> {location.address}</p>
<!-- In a real application, you would use a map component here -->
<div class="map-placeholder">
Karte für {location.coordinates.lat}, {location.coordinates.lng}
</div>
</article>
{
location.coordinates && (
<div class="mt-8 overflow-hidden rounded-xl border border-gray-200">
<a
href={`https://www.openstreetmap.org/?mlat=${location.coordinates.lat}&mlon=${location.coordinates.lng}#map=17/${location.coordinates.lat}/${location.coordinates.lng}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 bg-gray-50 px-4 py-4 text-sm text-gray-500 transition-colors hover:text-primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
In OpenStreetMap öffnen
</a>
</div>
)
}
</main>
</Layout>
<style>
.location-detail {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
color: #333;
}
.location-image {
width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 20px;
}
.category {
font-style: italic;
color: #666;
}
.description {
line-height: 1.6;
}
.address {
margin-top: 20px;
}
.map-placeholder {
margin-top: 20px;
background-color: #f0f0f0;
padding: 50px;
text-align: center;
border-radius: 8px;
}
.timeline {
margin-top: 30px;
}
.timeline h2 {
margin-bottom: 15px;
}
.timeline ul {
list-style-type: none;
padding-left: 0;
border-left: 2px solid #ccc;
margin-left: 10px;
}
.timeline li {
padding: 10px 20px;
position: relative;
}
.timeline li::before {
content: '';
width: 10px;
height: 10px;
background-color: #ccc;
border-radius: 50%;
position: absolute;
left: -6px;
top: 18px;
}
</style>

View file

@ -1,26 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
// This script is a placeholder for a data update agent.
// In a real application, this script would fetch data from an API,
// process it, and then write it to the locations.json file.
async function updateLocations() {
try {
const dataPath = path.join(process.cwd(), 'src', 'data', 'locations.json');
const data = await fs.readFile(dataPath, 'utf-8');
const locations = JSON.parse(data);
console.log('Successfully read location data.');
console.log(`Found ${locations.length} locations.`);
// Here you could add logic to fetch new data and compare it
// with the existing data to determine if an update is needed.
console.log('Location data is up to date.');
} catch (error) {
console.error('Error updating location data:', error);
}
}
updateLocations();

View file

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
primary: '#2563eb',
'primary-dark': '#1d4ed8',
},
},
},
plugins: [],
};

View file

@ -13,6 +13,7 @@
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
@ -20,6 +21,7 @@
"@tailwindcss/vite": "^4.1.7",
"@types/leaflet": "^1.9.8",
"@types/node": "^20.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
@ -32,11 +34,13 @@
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"leaflet": "^1.9.4"
"leaflet": "^1.9.4",
"svelte-i18n": "^4.0.1"
}
}

View file

@ -0,0 +1,40 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('citycorners_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('citycorners_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,82 @@
{
"app": {
"name": "CityCorners",
"tagline": "Entdecke Konstanz"
},
"nav": {
"explore": "Entdecken",
"map": "Karte",
"favorites": "Favoriten",
"settings": "Einstellungen",
"showNav": "Navigation einblenden",
"hideNav": "Navigation ausblenden"
},
"home": {
"title": "Entdecke Konstanz",
"subtitle": "Sehenswürdigkeiten, Restaurants, Museen und mehr",
"all": "Alle",
"loading": "Laden...",
"noResults": "Keine Locations gefunden."
},
"categories": {
"sight": "Sehenswürdigkeiten",
"restaurant": "Restaurants",
"shop": "Läden",
"museum": "Museen"
},
"category": {
"sight": "Sehenswürdigkeit",
"restaurant": "Restaurant",
"shop": "Laden",
"museum": "Museum"
},
"detail": {
"history": "Geschichte",
"openInMaps": "In OpenStreetMap öffnen",
"back": "Zurück zur Übersicht",
"notFound": "Location nicht gefunden."
},
"favorites": {
"title": "Favoriten",
"subtitle": "Deine gespeicherten Orte",
"empty": "Noch keine Favoriten. Tippe auf das Herz bei einer Location, um sie zu speichern.",
"loginRequired": "Melde dich an, um Favoriten zu speichern.",
"add": "Zu Favoriten hinzufügen",
"remove": "Aus Favoriten entfernen"
},
"map": {
"title": "Karte",
"subtitle": "Alle Orte in Konstanz"
},
"search": {
"placeholder": "Ort suchen...",
"noResults": "Keine Ergebnisse",
"searching": "Suche..."
},
"settings": {
"title": "Einstellungen",
"appearance": "Erscheinungsbild",
"mode": "Modus",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"colorScheme": "Farbschema",
"account": "Account",
"email": "E-Mail",
"logout": "Abmelden",
"loginPrompt": "Melde dich an, um Favoriten zu speichern und alle Features zu nutzen.",
"login": "Anmelden",
"register": "Registrieren",
"about": "Über CityCorners",
"aboutText": "CityCorners ist ein Stadtführer für Konstanz am Bodensee. Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden."
},
"auth": {
"loginTitle": "Login - CityCorners",
"registerTitle": "Registrieren - CityCorners"
},
"offline": {
"title": "Keine Verbindung",
"message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.",
"retry": "Erneut versuchen"
}
}

View file

@ -0,0 +1,82 @@
{
"app": {
"name": "CityCorners",
"tagline": "Discover Konstanz"
},
"nav": {
"explore": "Explore",
"map": "Map",
"favorites": "Favorites",
"settings": "Settings",
"showNav": "Show navigation",
"hideNav": "Hide navigation"
},
"home": {
"title": "Discover Konstanz",
"subtitle": "Sights, restaurants, museums and more",
"all": "All",
"loading": "Loading...",
"noResults": "No locations found."
},
"categories": {
"sight": "Sights",
"restaurant": "Restaurants",
"shop": "Shops",
"museum": "Museums"
},
"category": {
"sight": "Sight",
"restaurant": "Restaurant",
"shop": "Shop",
"museum": "Museum"
},
"detail": {
"history": "History",
"openInMaps": "Open in OpenStreetMap",
"back": "Back to overview",
"notFound": "Location not found."
},
"favorites": {
"title": "Favorites",
"subtitle": "Your saved places",
"empty": "No favorites yet. Tap the heart on a location to save it.",
"loginRequired": "Sign in to save favorites.",
"add": "Add to favorites",
"remove": "Remove from favorites"
},
"map": {
"title": "Map",
"subtitle": "All places in Konstanz"
},
"search": {
"placeholder": "Search places...",
"noResults": "No results",
"searching": "Searching..."
},
"settings": {
"title": "Settings",
"appearance": "Appearance",
"mode": "Mode",
"light": "Light",
"dark": "Dark",
"system": "System",
"colorScheme": "Color scheme",
"account": "Account",
"email": "Email",
"logout": "Sign out",
"loginPrompt": "Sign in to save favorites and use all features.",
"login": "Sign in",
"register": "Sign up",
"about": "About CityCorners",
"aboutText": "CityCorners is a city guide for Konstanz at Lake Constance. Discover sights, restaurants, museums and shops."
},
"auth": {
"loginTitle": "Login - CityCorners",
"registerTitle": "Sign up - CityCorners"
},
"offline": {
"title": "No connection",
"message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.",
"retry": "Try again"
}
}

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
@ -10,6 +11,8 @@
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { getPillAppItems } from '@manacore/shared-branding';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
const appItems = getPillAppItems('citycorners');
@ -34,13 +37,23 @@
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
let userEmail = $derived(authStore.user?.email || 'Menü');
// Language
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as 'de' | 'en');
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
let userEmail = $derived(authStore.user?.email || $_('nav.settings'));
let navItems = $derived<PillNavItem[]>([
{ href: '/', label: 'Entdecken', icon: 'compass' },
{ href: '/map', label: 'Karte', icon: 'mappin' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/', label: $_('nav.explore'), icon: 'compass' },
{ href: '/map', label: $_('nav.map'), icon: 'mappin' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
]);
function handleToggleTheme() {
@ -57,13 +70,6 @@
goto('/login');
}
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
@ -85,7 +91,7 @@
return data.locations.slice(0, 8).map((loc: any) => ({
id: loc.id,
title: loc.name,
subtitle: categoryLabels[loc.category] || loc.category,
subtitle: $_(`category.${loc.category}`),
icon: 'mappin' as const,
href: `/locations/${loc.id}`,
}));
@ -125,6 +131,9 @@
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
@ -140,9 +149,9 @@
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="Ort suchen..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
placeholder={$_('search.placeholder')}
emptyText={$_('search.noResults')}
searchingText={$_('search.searching')}
appIcon="mappin"
bottomOffset={inputBarBottomOffset}
hasFabRight={true}
@ -151,7 +160,7 @@
<button
class="pillnav-fab"
onclick={handleNavToggle}
title={showNav ? 'Navigation ausblenden' : 'Navigation einblenden'}
title={showNav ? $_('nav.hideNav') : $_('nav.showNav')}
>
{#if !showNav}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
@ -23,12 +24,7 @@
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categories = [
{ value: 'sight', label: 'Sehenswürdigkeiten' },
{ value: 'restaurant', label: 'Restaurants' },
{ value: 'shop', label: 'Läden' },
{ value: 'museum', label: 'Museen' },
];
const categoryKeys = ['sight', 'restaurant', 'shop', 'museum'];
let filtered = $derived(
selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations
@ -58,12 +54,12 @@
</script>
<svelte:head>
<title>CityCorners - Entdecke Konstanz</title>
<title>{$_('app.name')} - {$_('app.tagline')}</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Entdecke Konstanz</h1>
<p class="text-foreground-secondary">Sehenswürdigkeiten, Restaurants, Museen und mehr</p>
<h1 class="text-2xl font-bold text-foreground">{$_('home.title')}</h1>
<p class="text-foreground-secondary">{$_('home.subtitle')}</p>
</header>
<div class="mb-6 flex flex-wrap gap-2">
@ -73,24 +69,24 @@
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
Alle
{$_('home.all')}
</button>
{#each categories as cat}
{#each categoryKeys as cat}
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat.value
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat.value)}
onclick={() => (selectedCategory = cat)}
>
{cat.label}
{$_(`categories.${cat}`)}
</button>
{/each}
</div>
{#if loading}
<p class="text-foreground-secondary">Laden...</p>
<p class="text-foreground-secondary">{$_('home.loading')}</p>
{:else if filtered.length === 0}
<p class="text-foreground-secondary">Keine Locations gefunden.</p>
<p class="text-foreground-secondary">{$_('home.noResults')}</p>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
@ -112,8 +108,8 @@
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoritesStore.isFavorite(location.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
? $_('favorites.remove')
: $_('favorites.add')}
>
{#if favoritesStore.isFavorite(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
@ -143,7 +139,7 @@
<span
class="mb-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
{categories.find((c) => c.value === location.category)?.label ?? location.category}
{$_(`categories.${location.category}`)}
</span>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
@ -19,13 +20,6 @@
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
let favoriteLocations = $derived(allLocations.filter((l) => favoritesStore.isFavorite(l.id)));
onMount(async () => {
@ -52,32 +46,30 @@
</script>
<svelte:head>
<title>Favoriten - CityCorners</title>
<title>{$_('favorites.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Favoriten</h1>
<p class="text-foreground-secondary">Deine gespeicherten Orte</p>
<h1 class="text-2xl font-bold text-foreground">{$_('favorites.title')}</h1>
<p class="text-foreground-secondary">{$_('favorites.subtitle')}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="mb-4 text-foreground-secondary">Melde dich an, um Favoriten zu speichern.</p>
<p class="mb-4 text-foreground-secondary">{$_('favorites.loginRequired')}</p>
<a
href="/login"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
Anmelden
{$_('settings.login')}
</a>
</div>
{:else if loading}
<p class="text-foreground-secondary">Laden...</p>
<p class="text-foreground-secondary">{$_('home.loading')}</p>
{:else if favoriteLocations.length === 0}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">💙</span>
<p class="text-foreground-secondary">
Noch keine Favoriten. Tippe auf das Herz bei einer Location, um sie zu speichern.
</p>
<p class="text-foreground-secondary">{$_('favorites.empty')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -100,9 +92,7 @@
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="text-xs text-primary"
>{categoryLabels[location.category] || location.category}</span
>
<span class="text-xs text-primary">{$_(`category.${location.category}`)}</span>
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{location.name}
</h3>
@ -110,7 +100,7 @@
<button
class="flex-shrink-0 p-1 text-red-500 transition-colors hover:text-red-600"
onclick={(e) => handleRemove(e, location.id)}
title="Aus Favoriten entfernen"
title={$_('favorites.remove')}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
@ -106,9 +107,9 @@
{:else if !location}
<div class="py-20 text-center">
<span class="mb-4 block text-5xl">🔍</span>
<p class="text-foreground-secondary">Location nicht gefunden.</p>
<p class="text-foreground-secondary">{$_('detail.notFound')}</p>
<a href="/" class="mt-4 inline-block text-sm text-primary hover:underline"
>Zurück zur Übersicht</a
>{$_('detail.back')}</a
>
</div>
{:else}
@ -142,8 +143,8 @@
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={() => favoritesStore.toggle(location!.id)}
title={favoritesStore.isFavorite(location.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
? $_('favorites.remove')
: $_('favorites.add')}
>
{#if favoritesStore.isFavorite(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
@ -235,7 +236,7 @@
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
In OpenStreetMap öffnen
{$_('detail.openInMaps')}
</a>
</div>
{/if}
@ -243,7 +244,7 @@
<!-- Timeline -->
{#if location.timeline && location.timeline.length > 0}
<div>
<h2 class="mb-4 text-xl font-semibold text-foreground">Geschichte</h2>
<h2 class="mb-4 text-xl font-semibold text-foreground">{$_('detail.history')}</h2>
<div class="relative space-y-0">
{#each location.timeline as entry, i}
<div class="relative flex gap-4 pb-6 {i < location.timeline!.length - 1 ? '' : ''}">

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
interface Location {
id: string;
@ -88,14 +89,14 @@
</script>
<svelte:head>
<title>Karte - CityCorners</title>
<title>{$_('map.title')} - CityCorners</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</svelte:head>
<div class="map-page">
<header class="mb-4">
<h1 class="text-2xl font-bold text-foreground">Karte</h1>
<p class="text-foreground-secondary">Alle Orte in Konstanz</p>
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
<p class="text-foreground-secondary">{$_('map.subtitle')}</p>
</header>
<div class="legend mb-4 flex flex-wrap gap-3">

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
@ -23,21 +24,22 @@
</script>
<svelte:head>
<title>Einstellungen - CityCorners</title>
<title>{$_('settings.title')} - CityCorners</title>
</svelte:head>
<div class="space-y-8">
<h1 class="text-2xl font-bold text-foreground">Einstellungen</h1>
<h1 class="text-2xl font-bold text-foreground">{$_('settings.title')}</h1>
<!-- Theme Mode -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Erscheinungsbild</h2>
<h2 class="mb-4 text-lg font-semibold text-foreground">{$_('settings.appearance')}</h2>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-foreground-secondary">Modus</label>
<label class="mb-2 block text-sm font-medium text-foreground-secondary"
>{$_('settings.mode')}</label
>
<div class="flex gap-2">
{#each [{ value: 'light', label: '☀️ Hell' }, { value: 'dark', label: '🌙 Dunkel' }, { value: 'system', label: '💻 System' }] as opt}
{#each [{ value: 'light', label: `☀️ ${$_('settings.light')}` }, { value: 'dark', label: `🌙 ${$_('settings.dark')}` }, { value: 'system', label: `💻 ${$_('settings.system')}` }] as opt}
<button
class="rounded-lg px-4 py-2 text-sm transition-colors {theme.mode === opt.value
? 'bg-primary text-white'
@ -51,7 +53,9 @@
</div>
<div>
<label class="mb-2 block text-sm font-medium text-foreground-secondary">Farbschema</label>
<label class="mb-2 block text-sm font-medium text-foreground-secondary"
>{$_('settings.colorScheme')}</label
>
<div class="grid grid-cols-4 gap-2">
{#each themeVariants as v}
<button
@ -70,14 +74,13 @@
</div>
</section>
<!-- Account -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Account</h2>
<h2 class="mb-4 text-lg font-semibold text-foreground">{$_('settings.account')}</h2>
{#if authStore.isAuthenticated}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-foreground-secondary">E-Mail</span>
<span class="text-sm text-foreground-secondary">{$_('settings.email')}</span>
<span class="text-sm font-medium text-foreground">{authStore.user?.email}</span>
</div>
<hr class="border-border" />
@ -85,39 +88,33 @@
class="w-full rounded-lg bg-red-500/10 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/20"
onclick={handleLogout}
>
Abmelden
{$_('settings.logout')}
</button>
</div>
{:else}
<div class="space-y-3">
<p class="text-sm text-foreground-secondary">
Melde dich an, um Favoriten zu speichern und alle Features zu nutzen.
</p>
<p class="text-sm text-foreground-secondary">{$_('settings.loginPrompt')}</p>
<div class="flex gap-2">
<a
href="/login"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
Anmelden
{$_('settings.login')}
</a>
<a
href="/register"
class="rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
Registrieren
{$_('settings.register')}
</a>
</div>
</div>
{/if}
</section>
<!-- About -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Über CityCorners</h2>
<p class="text-sm text-foreground-secondary">
CityCorners ist ein Stadtführer für Konstanz am Bodensee. Entdecke Sehenswürdigkeiten,
Restaurants, Museen und Läden.
</p>
<h2 class="mb-4 text-lg font-semibold text-foreground">{$_('settings.about')}</h2>
<p class="text-sm text-foreground-secondary">{$_('settings.aboutText')}</p>
<p class="mt-2 text-xs text-foreground-secondary/60">Version 0.0.1</p>
</section>
</div>

View file

@ -1,12 +1,15 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import '$lib/i18n';
let { children } = $props();
</script>
<div class="min-h-screen flex items-center justify-center p-4 bg-background">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground">CityCorners</h1>
<p class="text-foreground-secondary mt-2">Entdecke Konstanz</p>
<h1 class="text-3xl font-bold text-foreground">{$_('app.name')}</h1>
<p class="text-foreground-secondary mt-2">{$_('app.tagline')}</p>
</div>
{@render children()}
</div>

View file

@ -2,9 +2,12 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { CitycornersLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
@ -21,6 +24,7 @@
return '/';
});
const translations = $derived(getLoginTranslations($locale || 'de'));
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
@ -34,7 +38,7 @@
</script>
<svelte:head>
<title>Login - CityCorners</title>
<title>{translations.title} - CityCorners</title>
</svelte:head>
<LoginPage
@ -51,6 +55,7 @@
forgotPasswordPath="/forgot-password"
lightBackground="#eff6ff"
darkBackground="#1e1b4b"
{translations}
{verified}
{initialEmail}
/>

View file

@ -1,8 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { CitycornersLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
@ -14,7 +19,7 @@
</script>
<svelte:head>
<title>Registrieren - CityCorners</title>
<title>{translations.title} - CityCorners</title>
</svelte:head>
<RegisterPage
@ -28,4 +33,5 @@
loginPath="/login"
lightBackground="#eff6ff"
darkBackground="#1e1b4b"
{translations}
/>

View file

@ -1,8 +1,10 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { isLoading as isLocaleLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
let { children } = $props();
@ -13,6 +15,7 @@
const cleanupErrorHandler = setupGlobalErrorHandler();
const init = async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
loading = false;
@ -26,7 +29,7 @@
<ToastContainer />
{#if loading}
{#if $isLocaleLoading || loading}
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="text-center">
<div

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import '$lib/i18n';
</script>
<svelte:head>
<title>{$_('offline.title')} - CityCorners</title>
</svelte:head>
<div class="min-h-screen bg-background flex items-center justify-center p-6">
<div class="text-center max-w-md">
<span class="mb-4 block text-6xl">📡</span>
<h1 class="text-2xl font-bold text-foreground mb-2">{$_('offline.title')}</h1>
<p class="text-foreground-secondary mb-6">{$_('offline.message')}</p>
<button
class="rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary/90"
onclick={() => window.location.reload()}
>
{$_('offline.retry')}
</button>
</div>
</div>

View file

@ -1,10 +1,24 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()],
plugins: [
sveltekit(),
tailwindcss(),
SvelteKitPWA(
createPWAConfig({
name: 'CityCorners - Konstanz Guide',
shortName: 'CityCorners',
description: 'Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden in Konstanz',
themeColor: '#2563eb',
categories: ['travel', 'lifestyle'],
})
),
],
server: {
port: 5196,
strictPort: true,

915
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff