mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 08:01:09 +02:00
feat(citycorners): add open/closed badges, map category filter, opening hours
- Add isOpenNow() utility that checks current time against opening hours - Show "Open now" / "Closed" badge on location cards and detail page - Add category filter pills to the map page (click to filter markers) - Add opening hours to seed data for cafés, bars, restaurant, shops, museums - Add missing category colors to detail page - i18n: openNow, closedNow, filterAll (DE/EN) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
491c71e2b8
commit
89e32d4798
7 changed files with 311 additions and 40 deletions
|
|
@ -56,6 +56,15 @@ async function seed() {
|
|||
latitude: 47.6589,
|
||||
longitude: 9.1795,
|
||||
imageUrl: '/images/ophelia.jpg',
|
||||
openingHours: {
|
||||
mo: 'closed',
|
||||
tu: 'closed',
|
||||
we: '18:30 - 22:00',
|
||||
th: '18:30 - 22:00',
|
||||
fr: '18:30 - 22:00',
|
||||
sa: '18:30 - 22:00',
|
||||
su: 'closed',
|
||||
},
|
||||
},
|
||||
|
||||
// === SHOPS ===
|
||||
|
|
@ -68,6 +77,15 @@ async function seed() {
|
|||
latitude: 47.6615,
|
||||
longitude: 9.1742,
|
||||
imageUrl: '/images/lago.jpg',
|
||||
openingHours: {
|
||||
mo: '09:30 - 20:00',
|
||||
tu: '09:30 - 20:00',
|
||||
we: '09:30 - 20:00',
|
||||
th: '09:30 - 20:00',
|
||||
fr: '09:30 - 20:00',
|
||||
sa: '09:30 - 20:00',
|
||||
su: 'closed',
|
||||
},
|
||||
},
|
||||
|
||||
// === MUSEUMS ===
|
||||
|
|
@ -80,6 +98,15 @@ async function seed() {
|
|||
address: 'Rosgartenstraße 3-5, 78462 Konstanz',
|
||||
latitude: 47.6612,
|
||||
longitude: 9.1753,
|
||||
openingHours: {
|
||||
mo: 'closed',
|
||||
tu: '10:00 - 18:00',
|
||||
we: '10:00 - 18:00',
|
||||
th: '10:00 - 18:00',
|
||||
fr: '10:00 - 18:00',
|
||||
sa: '10:00 - 17:00',
|
||||
su: '10:00 - 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Archäologisches Landesmuseum',
|
||||
|
|
@ -89,6 +116,15 @@ async function seed() {
|
|||
address: 'Benediktinerplatz 5, 78467 Konstanz',
|
||||
latitude: 47.6637,
|
||||
longitude: 9.1801,
|
||||
openingHours: {
|
||||
mo: 'closed',
|
||||
tu: '10:00 - 18:00',
|
||||
we: '10:00 - 18:00',
|
||||
th: '10:00 - 18:00',
|
||||
fr: '10:00 - 18:00',
|
||||
sa: '10:00 - 18:00',
|
||||
su: '10:00 - 18:00',
|
||||
},
|
||||
},
|
||||
|
||||
// === CAFÉS ===
|
||||
|
|
@ -101,6 +137,15 @@ async function seed() {
|
|||
address: 'Hussenstraße 13, 78462 Konstanz',
|
||||
latitude: 47.6609,
|
||||
longitude: 9.1749,
|
||||
openingHours: {
|
||||
mo: '08:00 - 18:00',
|
||||
tu: '08:00 - 18:00',
|
||||
we: '08:00 - 18:00',
|
||||
th: '08:00 - 18:00',
|
||||
fr: '08:00 - 18:00',
|
||||
sa: '09:00 - 18:00',
|
||||
su: '10:00 - 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Café Wessenberg',
|
||||
|
|
@ -111,6 +156,15 @@ async function seed() {
|
|||
address: 'Wessenbergstraße 41, 78462 Konstanz',
|
||||
latitude: 47.6614,
|
||||
longitude: 9.1739,
|
||||
openingHours: {
|
||||
mo: '07:30 - 18:30',
|
||||
tu: '07:30 - 18:30',
|
||||
we: '07:30 - 18:30',
|
||||
th: '07:30 - 18:30',
|
||||
fr: '07:30 - 18:30',
|
||||
sa: '08:00 - 18:00',
|
||||
su: '09:00 - 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Café Gessler 1159',
|
||||
|
|
@ -121,6 +175,15 @@ async function seed() {
|
|||
address: 'Bodanstraße 9, 78462 Konstanz',
|
||||
latitude: 47.6608,
|
||||
longitude: 9.173,
|
||||
openingHours: {
|
||||
mo: '06:30 - 19:00',
|
||||
tu: '06:30 - 19:00',
|
||||
we: '06:30 - 19:00',
|
||||
th: '06:30 - 19:00',
|
||||
fr: '06:30 - 19:00',
|
||||
sa: '07:00 - 18:00',
|
||||
su: '08:00 - 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Voglhaus Café',
|
||||
|
|
@ -131,6 +194,15 @@ async function seed() {
|
|||
address: 'Wessenbergstraße 8, 78462 Konstanz',
|
||||
latitude: 47.6619,
|
||||
longitude: 9.1744,
|
||||
openingHours: {
|
||||
mo: '09:00 - 18:00',
|
||||
tu: '09:00 - 18:00',
|
||||
we: '09:00 - 18:00',
|
||||
th: '09:00 - 18:00',
|
||||
fr: '09:00 - 18:00',
|
||||
sa: '09:00 - 18:00',
|
||||
su: '10:00 - 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Café Herr Hase',
|
||||
|
|
@ -141,6 +213,15 @@ async function seed() {
|
|||
address: 'Niederburggasse 2, 78462 Konstanz',
|
||||
latitude: 47.6623,
|
||||
longitude: 9.1762,
|
||||
openingHours: {
|
||||
mo: '08:30 - 17:00',
|
||||
tu: '08:30 - 17:00',
|
||||
we: '08:30 - 17:00',
|
||||
th: '08:30 - 17:00',
|
||||
fr: '08:30 - 17:00',
|
||||
sa: '09:00 - 17:00',
|
||||
su: 'closed',
|
||||
},
|
||||
},
|
||||
|
||||
// === BARS ===
|
||||
|
|
@ -153,6 +234,15 @@ async function seed() {
|
|||
address: 'Bodanstraße 18, 78462 Konstanz',
|
||||
latitude: 47.6611,
|
||||
longitude: 9.1736,
|
||||
openingHours: {
|
||||
mo: '18:00 - 01:00',
|
||||
tu: '18:00 - 01:00',
|
||||
we: '18:00 - 01:00',
|
||||
th: '18:00 - 02:00',
|
||||
fr: '18:00 - 03:00',
|
||||
sa: '18:00 - 03:00',
|
||||
su: 'closed',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Shamrock Irish Pub',
|
||||
|
|
@ -163,6 +253,15 @@ async function seed() {
|
|||
address: 'Bodanstraße 28, 78462 Konstanz',
|
||||
latitude: 47.6607,
|
||||
longitude: 9.1728,
|
||||
openingHours: {
|
||||
mo: '17:00 - 01:00',
|
||||
tu: '17:00 - 01:00',
|
||||
we: '17:00 - 01:00',
|
||||
th: '17:00 - 01:00',
|
||||
fr: '17:00 - 02:00',
|
||||
sa: '15:00 - 02:00',
|
||||
su: '15:00 - 00:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Seekuh',
|
||||
|
|
@ -173,6 +272,15 @@ async function seed() {
|
|||
address: 'Konradigasse 1, 78462 Konstanz',
|
||||
latitude: 47.6632,
|
||||
longitude: 9.1773,
|
||||
openingHours: {
|
||||
mo: '17:00 - 01:00',
|
||||
tu: '17:00 - 01:00',
|
||||
we: '17:00 - 01:00',
|
||||
th: '17:00 - 02:00',
|
||||
fr: '17:00 - 03:00',
|
||||
sa: '15:00 - 03:00',
|
||||
su: '15:00 - 00:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Brauhaus Johann Albrecht',
|
||||
|
|
@ -183,6 +291,15 @@ async function seed() {
|
|||
address: 'Konradigasse 2, 78462 Konstanz',
|
||||
latitude: 47.663,
|
||||
longitude: 9.177,
|
||||
openingHours: {
|
||||
mo: '11:00 - 23:00',
|
||||
tu: '11:00 - 23:00',
|
||||
we: '11:00 - 23:00',
|
||||
th: '11:00 - 23:00',
|
||||
fr: '11:00 - 00:00',
|
||||
sa: '11:00 - 00:00',
|
||||
su: '11:00 - 22:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Schwarze Katz',
|
||||
|
|
@ -193,6 +310,15 @@ async function seed() {
|
|||
address: 'Katzgasse 7, 78462 Konstanz',
|
||||
latitude: 47.6617,
|
||||
longitude: 9.1752,
|
||||
openingHours: {
|
||||
mo: 'closed',
|
||||
tu: '19:00 - 01:00',
|
||||
we: '19:00 - 01:00',
|
||||
th: '19:00 - 02:00',
|
||||
fr: '19:00 - 03:00',
|
||||
sa: '19:00 - 03:00',
|
||||
su: 'closed',
|
||||
},
|
||||
},
|
||||
|
||||
// === PARKS ===
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@
|
|||
"website": "Webseite",
|
||||
"phone": "Telefon",
|
||||
"openingHours": "Öffnungszeiten",
|
||||
"closed": "Geschlossen"
|
||||
"closed": "Geschlossen",
|
||||
"openNow": "Jetzt geöffnet",
|
||||
"closedNow": "Geschlossen"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Montag",
|
||||
|
|
@ -116,7 +118,8 @@
|
|||
"locateMe": "Mein Standort",
|
||||
"yourLocation": "Du bist hier",
|
||||
"geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.",
|
||||
"geolocationError": "Standort konnte nicht ermittelt werden."
|
||||
"geolocationError": "Standort konnte nicht ermittelt werden.",
|
||||
"filterAll": "Alle"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Ort suchen...",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@
|
|||
"website": "Website",
|
||||
"phone": "Phone",
|
||||
"openingHours": "Opening hours",
|
||||
"closed": "Closed"
|
||||
"closed": "Closed",
|
||||
"openNow": "Open now",
|
||||
"closedNow": "Closed"
|
||||
},
|
||||
"days": {
|
||||
"mo": "Monday",
|
||||
|
|
@ -116,7 +118,8 @@
|
|||
"locateMe": "My location",
|
||||
"yourLocation": "You are here",
|
||||
"geolocationNotSupported": "Geolocation is not supported.",
|
||||
"geolocationError": "Could not determine location."
|
||||
"geolocationError": "Could not determine location.",
|
||||
"filterAll": "All"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search places...",
|
||||
|
|
|
|||
31
apps/citycorners/apps/web/src/lib/opening-hours.ts
Normal file
31
apps/citycorners/apps/web/src/lib/opening-hours.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const DAY_KEYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
|
||||
|
||||
/**
|
||||
* Check if a location is currently open based on its opening hours.
|
||||
* Returns null if no opening hours are provided.
|
||||
*/
|
||||
export function isOpenNow(openingHours?: Record<string, string> | null): boolean | null {
|
||||
if (!openingHours || Object.keys(openingHours).length === 0) return null;
|
||||
|
||||
const now = new Date();
|
||||
const dayKey = DAY_KEYS[now.getDay()];
|
||||
const hours = openingHours[dayKey];
|
||||
|
||||
if (!hours || hours === 'closed') return false;
|
||||
|
||||
// Parse "HH:MM - HH:MM" format
|
||||
const match = hours.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, openH, openM, closeH, closeM] = match;
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const openMinutes = parseInt(openH) * 60 + parseInt(openM);
|
||||
const closeMinutes = parseInt(closeH) * 60 + parseInt(closeM);
|
||||
|
||||
// Handle overnight hours (e.g., 22:00 - 03:00)
|
||||
if (closeMinutes < openMinutes) {
|
||||
return currentMinutes >= openMinutes || currentMinutes < closeMinutes;
|
||||
}
|
||||
|
||||
return currentMinutes >= openMinutes && currentMinutes < closeMinutes;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { favoritesStore } from '$lib/stores/favorites.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { isOpenNow } from '$lib/opening-hours';
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
latitude?: number;
|
||||
longitude?: number;
|
||||
imageUrl?: string;
|
||||
openingHours?: Record<string, string>;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
|
|
@ -256,11 +258,25 @@
|
|||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
<span
|
||||
class="mb-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
{$_(`categories.${location.category}`)}
|
||||
</span>
|
||||
<div class="mb-1 flex flex-wrap items-center gap-1.5">
|
||||
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{$_(`categories.${location.category}`)}
|
||||
</span>
|
||||
{@const openStatus = isOpenNow(location.openingHours)}
|
||||
{#if openStatus === true}
|
||||
<span
|
||||
class="inline-block rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-600 dark:text-green-400"
|
||||
>
|
||||
{$_('detail.openNow')}
|
||||
</span>
|
||||
{:else if openStatus === false}
|
||||
<span
|
||||
class="inline-block rounded-full bg-red-500/10 px-2 py-0.5 text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{$_('detail.closedNow')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
|
||||
{location.name}
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { favoritesStore } from '$lib/stores/favorites.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { isOpenNow } from '$lib/opening-hours';
|
||||
|
||||
interface TimelineEntry {
|
||||
year: string;
|
||||
|
|
@ -66,6 +67,13 @@
|
|||
restaurant: '#dc2626',
|
||||
shop: '#16a34a',
|
||||
museum: '#9333ea',
|
||||
cafe: '#b45309',
|
||||
bar: '#ea580c',
|
||||
park: '#15803d',
|
||||
beach: '#0891b2',
|
||||
hotel: '#4f46e5',
|
||||
event_venue: '#db2777',
|
||||
viewpoint: '#0ea5e9',
|
||||
};
|
||||
|
||||
let isOwner = $derived(
|
||||
|
|
@ -337,14 +345,28 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Category badge -->
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<!-- Category badge + open status -->
|
||||
<div class="absolute bottom-4 left-4 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
|
||||
style="background: {categoryColors[location.category] || '#6b7280'}cc"
|
||||
>
|
||||
{$_(`category.${location.category}`)}
|
||||
</span>
|
||||
{@const openStatus = isOpenNow(location.openingHours)}
|
||||
{#if openStatus === true}
|
||||
<span
|
||||
class="rounded-full bg-green-500/90 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
|
||||
>
|
||||
{$_('detail.openNow')}
|
||||
</span>
|
||||
{:else if openStatus === false}
|
||||
<span
|
||||
class="rounded-full bg-red-500/80 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
|
||||
>
|
||||
{$_('detail.closedNow')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@
|
|||
let map: any = null;
|
||||
let locating = $state(false);
|
||||
let locationError = $state('');
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const categoryKeys = [
|
||||
'sight',
|
||||
'restaurant',
|
||||
'shop',
|
||||
'museum',
|
||||
'cafe',
|
||||
'bar',
|
||||
'park',
|
||||
'beach',
|
||||
'hotel',
|
||||
'event_venue',
|
||||
'viewpoint',
|
||||
];
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
sight: '#2563eb',
|
||||
|
|
@ -36,36 +51,36 @@
|
|||
viewpoint: '#0ea5e9',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(api('/locations'));
|
||||
const data = await res.json();
|
||||
locations = data.locations;
|
||||
} catch (err) {
|
||||
console.error('Failed to load locations:', err);
|
||||
let allMarkers: any[] = [];
|
||||
let markerLayer: any = null;
|
||||
let leafletLib: any = null;
|
||||
|
||||
function updateMarkers() {
|
||||
if (!map || !leafletLib) return;
|
||||
const L = leafletLib;
|
||||
|
||||
// Remove existing markers
|
||||
if (markerLayer) {
|
||||
map.removeLayer(markerLayer);
|
||||
}
|
||||
for (const m of allMarkers) {
|
||||
map.removeLayer(m);
|
||||
}
|
||||
allMarkers = [];
|
||||
|
||||
if (!browser) return;
|
||||
const filtered = selectedCategory
|
||||
? locations.filter((l) => l.category === selectedCategory)
|
||||
: locations;
|
||||
|
||||
const L = await import('leaflet');
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
const useCluster = filtered.length >= 10;
|
||||
|
||||
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const useCluster = locations.length >= 10;
|
||||
let markerLayer: any;
|
||||
|
||||
if (useCluster) {
|
||||
const { default: MCG } = await import('leaflet.markercluster');
|
||||
if (useCluster && (L as any).markerClusterGroup) {
|
||||
markerLayer = (L as any).markerClusterGroup();
|
||||
} else {
|
||||
markerLayer = null;
|
||||
}
|
||||
|
||||
for (const loc of locations) {
|
||||
for (const loc of filtered) {
|
||||
if (loc.latitude && loc.longitude) {
|
||||
const color = categoryColors[loc.category] || '#6b7280';
|
||||
|
||||
|
|
@ -91,6 +106,7 @@
|
|||
markerLayer.addLayer(marker);
|
||||
} else {
|
||||
marker.addTo(map);
|
||||
allMarkers.push(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +114,45 @@
|
|||
if (useCluster && markerLayer) {
|
||||
map.addLayer(markerLayer);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(api('/locations?limit=100'));
|
||||
const data = await res.json();
|
||||
locations = data.locations;
|
||||
} catch (err) {
|
||||
console.error('Failed to load locations:', err);
|
||||
}
|
||||
|
||||
if (!browser) return;
|
||||
|
||||
leafletLib = await import('leaflet');
|
||||
const L = leafletLib;
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
|
||||
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
try {
|
||||
await import('leaflet.markercluster');
|
||||
} catch {
|
||||
// clustering not available
|
||||
}
|
||||
|
||||
updateMarkers();
|
||||
});
|
||||
|
||||
// Re-render markers when category filter changes
|
||||
$effect(() => {
|
||||
const _ = selectedCategory;
|
||||
if (map && leafletLib) {
|
||||
updateMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
function handleLocateMe() {
|
||||
|
|
@ -187,12 +242,27 @@
|
|||
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">{locationError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="legend mb-4 flex flex-wrap gap-3">
|
||||
{#each Object.entries(categoryColors) as [key, color]}
|
||||
<div class="flex items-center gap-1.5 text-sm text-foreground-secondary">
|
||||
<div class="w-3 h-3 rounded-full" style="background:{color}"></div>
|
||||
{$_(`category.${key}`)}
|
||||
</div>
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
|
||||
null
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
|
||||
onclick={() => (selectedCategory = null)}
|
||||
>
|
||||
{$_('map.filterAll')}
|
||||
</button>
|
||||
{#each categoryKeys as cat}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
|
||||
cat
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
|
||||
onclick={() => (selectedCategory = selectedCategory === cat ? null : cat)}
|
||||
>
|
||||
<div class="w-2.5 h-2.5 rounded-full" style="background:{categoryColors[cat]}"></div>
|
||||
{$_(`category.${cat}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue