Overhaul content management
Some checks failed
Build and deploy updated apps / Build & deploy (push) Failing after 1m7s

This commit is contained in:
Dominik Milacher 2025-10-22 19:31:38 +02:00
parent 1025cc0786
commit 73083ded58
51 changed files with 2154 additions and 1614 deletions

View File

@ -1,170 +1,3 @@
import { LegalPageBuilder } from '#layers/content/utils/content-legal'
import type { BusinessInfo, ContentMatrix } from '#layers/content/utils/content-types'
import { t } from '#layers/content/utils/content-template'
const matrix: ContentMatrix = {
locale: {
default: 'de',
list: [
{ code: 'de', name: { de: 'Deutsch', en: 'German' }, icon: 'german' },
{ code: 'en', name: { de: 'Englisch', en: 'English' }, icon: 'english' },
],
cookieMaxAge: 60 * 60 * 24 * 365 * 3
},
variant: {
default: 'winter',
list: [
{ code: 'summer', name: { de: 'Sommer', en: 'Summer' }, icon: 'i-lucide-sun' },
{ code: 'winter', name: { de: 'Winter', en: 'Winter' }, icon: 'i-lucide-snowflake' },
],
cookieMaxAge: 60 * 60 * 24
}
}
const business: BusinessInfo = {
uid: 'panoramablick-saalbach.at',
name: {
de: 'Landhaus Panoramablick',
en: 'Guesthouse Panoramablick'
},
operator: 'Monika & Norbert Pail',
address: 'Unterer Ronachweg 731, 5753 Saalbach, AT',
phone: '+43 664 7904775',
email: 'info@panoramablick-saalbach.at',
vat: 'ATU 62062608',
authority: 'Bezirkshauptmannschaft Zell am See',
membership: 'Wirtschaftskammer Salzburg',
regulation: 'www.ris.bka.gv.at'
}
const apartments = {
1: {
price: 98,
capacity: {
min: 2,
max: 4
},
size: 38
},
2: {
price: 155,
capacity: {
min: 2,
max: 5
},
size: 50
},
3: {
price: 155,
capacity: {
min: 2,
max: 6
},
size: 50
}
}
function price(apartment: number) {
return {
de: `Ab ${apartments[apartment].price},- pro Tag`,
en: `Starting at ${apartments[apartment].price},- per day`
}
}
function capacity(apartment: number) {
return {
de: `${apartments[apartment].capacity.min} bis ${apartments[apartment].capacity.max} Personen`,
en: `${apartments[apartment].capacity.min} to ${apartments[apartment].capacity.max} persons`,
}
}
const apartment_features = [
{
icon: 'shower-head',
label: {
de: 'Badezimmer mit Dusche',
en: 'Bathroom with shower'
}
},
{
icon: 'toilet',
label: {
de: 'Bad und WC getrennt',
en: 'Bathroom and toilet in separate rooms'
}
},
{
icon: 'wifi',
label: {
de: 'Schnelles WLAN',
en: 'High-speed WiFi'
}
},
{
icon: 'tv',
label: {
de: 'Fernseher',
en: 'TV'
}
},
{
icon: 'microwave',
label: {
de: 'Mikrowelle',
en: 'Microwave'
}
},
{
icon: 'microwave',
label: {
de: 'Backrohr',
en: 'Oven'
}
},
{
icon: 'bubbles',
label: {
de: 'Geschirrspüler',
en: 'Dishwasher'
}
},
{
icon: 'coffee',
label: {
de: 'Kaffeemaschine',
en: 'Coffee maker'
}
},
{
icon: 'coffee',
label: {
de: 'Wasserkocher',
en: 'Electric water boiler'
}
},
{
icon: 'bed',
label: {
de: 'Bettwäsche',
en: 'Bed sheets'
}
},
{
icon: 'layers',
label: {
de: 'Handtücher',
en: 'Towels'
}
},
{
icon: 'wind',
label: {
de: 'Haarföhn',
en: 'Hairdryer'
}
}
]
export default defineAppConfig({
ui: {
colors: {
@ -173,646 +6,4 @@ export default defineAppConfig({
neutral: 'sandstone'
}
},
content: {
matrix: matrix,
business: business,
legal: LegalPageBuilder.build(),
cookies: {
title: {
de: 'Ihre Privatsphäre ist uns wichtig',
en: 'We value your privacy'
},
description: {
de: 'Wir verwenden keine Tracking-Cookies. Genießen Sie Ihren Aufenthalt auf unserer Website!',
en: 'We do not use any tracking cookies. Enjoy your stay on our website!'
}
},
header: {
home: business.name,
apartments: {
de: 'Apartments & Preise',
en: 'Apartments & Prices'
},
book: {
de: 'Buchen',
en: 'Book'
},
contact: {
de: 'Kontakt',
en: 'Contact'
}
},
footer: {
image: '/host.webp',
questions: {
de: 'Fragen oder Wünsche?',
en: 'Questions or requests?'
},
prompt: {
de: 'Monika freut sich von Ihnen zu hören!',
en: 'Monika is looking forward to hearing from you!'
},
imprint: {
de: 'Impressum',
en: 'Imprint'
},
privacy: {
de: 'Datenschutz',
en: 'Privacy'
},
accessibility: {
de: 'Barrierefreiheit',
en: 'Accessibility'
}
},
button: {
apartments: {
de: 'Apartments',
en: 'Apartments'
},
book: {
de: 'Buchen',
en: 'Book'
},
contact: {
de: 'Kontakt',
en: 'Contact'
},
hosts: {
de: 'Die Gastgeber',
en: 'The Hosts'
},
map: {
de: 'Maps Öffnen',
en: 'Open Maps'
}
},
landing: {
welcome: {
title: {
de: t`Willkommen im ${'business.name'} in Saalbach`,
en: t`Welcome to ${'business.name'} in Saalbach`
},
description: {
de: 'Genießen Sie erholsame Tage inmitten der Salzburger Natur mit herrlichem Panoramablick auf die Berge von Saalbach. Unser Landhaus bietet gemütliche Apartments und herzliche Gastfreundschaft in perfekter Lage.',
en: 'Enjoy relaxing days amidst the Salzburg nature with a stunning panoramic view of the Saalbach mountains. Our guesthouse offers cozy apartments and warm hospitality in a perfect location.'
},
images: {
summer: [
'/landing/2.webp',
'/landing/1.webp',
'/landing/3.webp',
'/landing/4.webp'
],
winter: [
'/landing/w1.webp',
'/landing/w2.webp'
]
},
joker: {
'summer': true,
'winter': false
}
},
highlight: {
title: {
de: 'Erleben Sie Natur hautnah',
en: 'Experience nature close up'
},
description: {
'de@summer': 'Unser Landhaus liegt eingebettet in der idyllischen Salzburger Landschaft mit atemberaubendem Panoramablick auf Saalbach. Ob Wandern, Radfahren, die Umgebung erkunden, oder Entspannte Momente in der Sauna bei uns finden Sie alles, was das Sommerherz begehrt.',
'de@winter': 'Unser Landhaus liegt eingebettet in der idyllischen Salzburger Landschaft mit atemberaubendem Panoramablick auf Saalbach. Ob Schifahren, Winterwandern, die Umgebung erkunden, oder Entspannte Momente in der Sauna bei uns finden Sie alles, was das Winterherz begehrt.',
'en@summer': 'Our guesthouse is located in the idyllic Salzburg countryside with breathtaking panoramic views of Saalbach. Whether hiking, cycling, exploring the surroundings, or relaxing moments in the sauna here youll find everything your heart desires.',
'en@winter': 'Our guesthouse is located in the idyllic Salzburg countryside with breathtaking panoramic views of Saalbach. Whether skiing, winter hiking, exploring the surroundings, or enjoying relaxing moments in the sauna here youll find everything your heart desires.'
},
image: {
left: '/ap.webp',
right: '/sauna.webp'
}
},
location: {
title: {
de: 'Logieren Sie in bester Lage',
en: 'Stay at the best location'
},
description: {
'de@summer': 'Unser Landhaus liegt mitten in Saalbach. In wenigen Gehminuten sind Sie bei den Gondeln, im Bikepark, sowie im wunderschönen Ortskern von Saalbach. Lassen Sie Ihr Auto den Urlaub über stehen und genießen Sie die Zeit bei uns.',
'de@winter': 'Unser Landhaus liegt mitten in Saalbach. In wenigen Gehminuten sind sie an der Skipiste sowie im wunderschönen Ortskern von Saalbach. Lassen Sie Ihr Auto den Urlaub über stehen und genießen Sie die Zeit bei uns.',
'en@summer': 'Our guesthouse is located right in the heart of Saalbach. Within a few minutes\' walk, you\'ll reach the gondolas, the bike park, and the beautiful town center of Saalbach. Leave your car parked for the entire vacation and enjoy your time with us.',
'en@winter': 'Our guesthouse is located right in the heart of Saalbach. Within a few minutes\' walk, you\'ll be at the ski slope and the beautiful town center of Saalbach. Leave your car parked for the entire vacation and enjoy your time with us.'
},
image: '/maps.webp'
},
apartments: {
title: {
de: 'Unsere Apartments',
en: 'Our apartments'
},
description: {
'de@summer': 'Wählen Sie aus unseren neu renovierten Appartements. Die Wohnungen sind großzügig gestaltet und bieten alle einen herrlichen Blick in die Saalbacher Berglandschaft. Dank einer raffinierter Bauweise genießen Sie auch an den heißesten Sommerabenden angenehme Temperaturen in unseren Zimmern, ganz ohne Klimaanlage. Konstante Wohlfühltemperaturen laden zum druchschlafen und erholen ein.',
'de@winter': 'Wählen Sie aus unseren neu renovierten Appartements. Die Wohnungen sind großzügig gestaltet und bieten alle einen herrlichen Blick in die Saalbacher Berglandschaft. Freuen Sie sich auf gemütlich warme Winterabende in unseren drei Wohlfühlappartements.',
'en@summer': 'Choose from our newly renovated apartments. The units are spaciously designed and all offer a wonderful view of the Saalbach mountain landscape. Thanks to clever construction, you can enjoy comfortable temperatures in our rooms even on the hottest summer evenings—completely without air conditioning. Consistent, pleasant temperatures invite you to sleep well and relax.',
'en@winter': 'Choose from our newly renovated apartments. The units are spaciously designed and all offer a wonderful view of the Saalbach mountain landscape. Look forward to cozy, warm winter evenings in our three comfort apartments.'
}
},
features: {
title: {
de: 'Darauf können Sie sich freuen!',
en: 'Thats something to look forward to!'
},
description1: {
de: 'Was Sie in Ihrem Urlaub im Landhaus Appartement Panoramablick erwartet. Unser Tipp:',
en: 'What awaits you during your stay at Landhaus Appartement Panoramablick. Our insider tip:'
},
description2: {
de: 'Reservieren',
en: 'Book'
},
description3: {
de: 'Sie online beim Sportgeschäft in nächster Nähe und sichern sich dabei exklusive Rabatte.',
en: 'online at the nearby sports shop and secure exclusive discounts.'
},
list: [
{
icon: 'heart-handshake',
label: {
de: 'Herzliche Gastgeber',
en: 'Warm-hearted hosts'
},
detail: {
de: 'Persönliche Tipps & herzlicher Service direkt im Haus',
en: 'Personalized tips & warm service right in the house'
}
},
{
icon: 'heater',
label: {
de: 'Sauna im Haus',
en: 'Sauna available'
},
detail: {
de: 'Sauna inklusive Infrarotkabine und Dampfsauna',
en: 'Sauna including infrared cabin and steam sauna'
}
},
{
icon: 'wifi',
label: {
de: 'Schnelles WLAN',
en: 'Fast WiFi'
},
detail: {
de: 'Professionelles WLAN Equipment in jedem Appartement',
en: 'Professional Wi-Fi equipment in every apartment'
}
},
{
icon: 'car-front',
label: {
de: 'Kostenlos parken',
en: 'Free parking'
},
detail: {
de: 'Kostenlose Stellplätze direkt vor der Tür',
en: 'Free parking spaces right outside the door'
}
},
{
icon: {
'summer': 'bike',
'winter': 'lock'
},
label: {
'de@summer': 'Bikekeller',
'de@winter': 'Skikeller',
'en@summer': 'Bike storage room',
'en@winter': 'Ski storage room'
},
detail: {
de: 'Platz für Ihr Urlaubsequipment',
en: 'Space for your holiday equipment'
}
},
{
icon: 'flame-kindling',
label: {
de: 'Grillplatz',
en: 'Barbecue'
},
detail: {
de: 'Kugelgrill und Smoker leihbar',
en: 'Kettle grill and smoker available for rent'
}
},
{
icon: 'mountain',
label: {
de: 'Exzellente Lage',
en: 'Excellent location'
},
detail: {
de: '5 Min. zum Ortskern',
en: '5 minutes to the town center'
}
},
{
icon: 'cable-car',
label: {
'de@summer': 'Bei den Gondeln',
'de@winter': 'An der Skipiste',
'en@summer': 'Right next to the gondolas',
'en@winter': 'Ski-in/ski-out location'
},
detail: {
'de@summer': 'Direkter Einstieg in den Bikepark Saalbach',
'de@winter': 'Direkter Einstieg in den Skizirkus Saalbach',
'en@summer': 'Direct access to the Saalbach bike park',
'en@winter': 'The house is located next to the ski slope'
}
},
{
icon: 'id-card-lanyard',
label: {
'summer': 'Joker Card',
'winter': 'Guest Mobility Ticket'
},
detail: {
'de@summer': 'Kostenlose Aktivitäten von der ersten bis zur letzten Urlaubsminute inklusive!',
'de@winter': 'Kostenlose Fahrten mit öffentlichen Verkehrsmitteln im Bundesland Salzburg',
'en@summer': 'Free activities included from the first to the last minute of your vacation',
'en@winter': 'Free rides on public transportation throughout Salzburg'
}
},
{
icon: 'bed',
label: {
de: 'Erholsamer Schlaf',
en: 'Restful sleep'
},
detail: {
'de@summer': 'Angenehme Zimmertemperaturen auch an heißen Sommertagen',
'de@winter': 'Kuschelige Wohlfühlbetten für einen erholsamen Schlaf',
'en@summer': 'Comfortable room temperatures even on hot summer days',
'en@winter': 'Cozy comfort beds for a restful sleep'
}
},
{
icon: 'trees',
label: {
de: 'Atemberaubendes Panorama',
en: 'Breathtaking panorama'
},
detail: {
de: 'Genießen Sie die Aussicht über das Home of Lässig',
en: 'Enjoy the wonderful view over the Home of Lässig'
}
},
{
icon: 'store',
label: {
de: 'Sportgeschäft',
en: 'Sports shop'
},
detail: {
de: 'Exklusive Rabatte bei Sport Bründl bei Vorabreservierung',
en: 'Exclusive discounts at Sport Bründl with advance reservation'
}
}
]
}
},
contact: {
title: {
de: 'Kontakt aufnehmen',
en: 'Contact us'
},
description: {
de: 'Wir freuen uns auf Ihre Anfrage Sie erreichen uns per Telefon, SMS, WhatsApp, E-Mail oder über das untenstehende Formular.',
en: 'Were happy to hear from you contact us by phone, SMS, WhatsApp, email, or through the form below.'
},
phone: business.phone,
email: business.email,
online1: {
de: 'Sie wollen direkt online buchen?',
en: 'Ready to book online?'
},
online2: {
de: 'Hier',
en: 'Here'
},
online3: {
de: 'geht\'s lang!',
en: 'you go!'
},
form: {
name: {
prompt: {
de: 'Ihr Name',
en: 'Your name'
},
invalid: {
de: 'Name zu kurz',
en: 'Name too short'
}
},
email: {
prompt: {
de: 'Ihre E-Mail',
en: 'Your email address'
},
invalid: {
de: 'E-Mail Adresse nicht gültig',
en: 'Email address not valid'
}
},
subject: {
prompt: {
de: 'Betreff',
en: 'Subject'
},
invalid: {
de: 'Betreff zu kurz',
en: 'Subject too short'
}
},
message: {
prompt: {
de: 'Ihre Anfrage',
en: 'Your inquiry'
},
invalid: {
de: 'Nachricht zu kurz',
en: 'Message too short'
}
},
send: {
de: 'Senden',
en: 'Send'
},
sent: {
title: {
de: 'Nachricht gesendet',
en: 'Message sent'
},
description: {
de: 'Vielen Dank - wir melden uns bald.',
en: 'Thank you - we\'ll be in touch soon.'
}
},
error: {
title: {
de: 'Fehler',
en: 'Error'
},
description: {
de: 'Die Nachricht konnte nicht versendet werden - bitte versuchen Sie es erneut.',
en: 'The message could not be sent - please try again.'
}
}
},
heroes: {
parents: {
title: 'Monika & Norbert',
description: {
de: 'Herzliche Gastgeber immer mit einem Tipp zur Region parat.',
en: 'Genuine hospitality and always a great tip for exploring the area.'
},
image: '/family/monika-norbert.webp'
},
children: {
title: 'Sabrina & Daniel',
description: {
de: 'Jung, aktiv und voller Energie Sohn Daniel und Tochter Sabrina bringen Schwung ins Haus.',
en: 'Energetic and full of life Daniel and Sabrina keep the house buzzing.'
},
image: '/family/sabrina-daniel.webp'
}
},
},
apartments: {
'highlight': {
title: {
de: 'Darauf können Sie sich freuen!',
en: 'Heres what you can look forward to!'
},
description: {
de: 'Was Sie in Ihrem Urlaub im Landhaus Appartement Panoramablick erwartet.',
en: 'What to expect during your vacation at Landhaus Appartement Panoramablick.'
},
image: {
left: '/ap.webp',
right: '/sauna.webp'
}
},
list: [
{
id: 'apartment-1',
title: 'Apartment 1',
subtitle: {
de: `${apartments[1].size} m² Urlaubsvergnügen! Dieses gemütliche Appartement bietet Ihnen Platz für bis zu ${apartments[1].capacity.max} Personen. Freuen Sie sich auf 1 Schlafzimmer, eine Wohnküche mit ausziehbarer Couch, Badezimmer, eine kleine Terrasse und ein getrenntes WC.`,
en: `${apartments[1].size} m² of holiday comfort! This cozy apartment comfortably accommodates up to ${apartments[1].capacity.max} guests. It features one bedroom, an open-plan kitchen and living area with a pull-out sofa, a bathroom, a small terrace, and a separate toilet.`
},
thumbnail: '/apartments/2/4.webp',
highlights: [
capacity(1),
price(1)
],
images: [
'/apartments/1/1.webp',
'/apartments/1/2.webp',
'/apartments/1/3.webp',
'/apartments/1/4.webp',
'/apartments/1/5.webp',
'/apartments/1/6.webp',
'/apartments/1/7.webp',
'/apartments/1/8.webp',
'/apartments/1/9.webp',
'/apartments/1/10.webp',
'/apartments/1/11.webp'
],
features: [
{
icon: 'coins',
label: price(1)
},
{
icon: 'users',
label: capacity(1),
},
{
icon: 'scaling',
label: `${apartments[1].size}`
},
{
icon: 'mountain',
label: {
de: 'Terrasse mit idyllischem Ausblick',
en: 'Terrace with idyllic view'
}
},
{
icon: 'bed-double',
label: {
de: 'Schlafzimmer mit Doppelbett',
en: 'Bedroom with double bed'
}
},
{
icon: 'sofa',
label: {
de: 'Wohnküche mit ausziehbarem Schlafsofa',
en: 'Living/kitchen area with sofa bed'
}
},
...apartment_features
]
},
{
id: 'apartment-2',
title: 'Apartment 2',
subtitle: {
de: `Wohnkomfort auf ${apartments[2].size} m²! Dieses großzügige Appartement bietet Ihnen Platz für bis zu ${apartments[2].capacity.max} Personen und bietet 2 Schlafzimmer, eine Wohnküche, Badezimmer, einen Balkon und ein getrenntes WC.`,
en: `Enjoy ${apartments[2].size} m² of comfortable living! This large apartment sleeps up to ${apartments[2].capacity.max} people and includes 2 bedrooms, a combined kitchen and living room, a bathroom, a balcony, and a separate toilet.`
},
thumbnail: '/apartments/2/8.webp',
highlights: [
capacity(2),
price(2)
],
images: [
'/apartments/2/1.webp',
'/apartments/2/2.webp',
'/apartments/2/3.webp',
'/apartments/2/4.webp',
'/apartments/2/5.webp',
'/apartments/2/6.webp',
'/apartments/2/7.webp',
'/apartments/2/8.webp',
'/apartments/2/9.webp',
'/apartments/2/10.webp',
'/apartments/2/11.webp',
'/apartments/2/12.webp',
'/apartments/2/13.webp'
],
features: [
{
icon: 'coins',
label: price(2)
},
{
icon: 'users',
label: capacity(2),
},
{
icon: 'scaling',
label: `${apartments[2].size}`
},
{
icon: 'mountain',
label: {
de: 'Balkon mit idyllischem Ausblick',
en: 'Balcony with idyllic view'
}
},
{
icon: 'bed-double',
label: {
de: 'Schlafzimmer 1 mit Doppelbett',
en: 'Bedroom 1 with double bed'
}
},
{
icon: 'bed-double',
label: {
de: 'Schlafzimmer 2 mit Doppelbett & Schlafsofa',
en: 'Bedroom 2 with double bed and sofa bed'
}
},
{
icon: 'sofa',
label: {
de: 'Wohnküche',
en: 'Living/kitchen area'
}
},
...apartment_features
]
},
{
id: 'apartment-3',
title: 'Apartment 3',
subtitle: {
de: `Wohlfühlen auf ${apartments[3].size} m²! Dieses geräumige Appartement bietet Ihnen Platz für bis zu ${apartments[3].capacity.max} Personen und bietet 2 Schlafzimmer, eine Wohnküche, Badezimmer, einen Balkon und ein getrenntes WC.`,
en: `Spacious ${apartments[3].size} m² apartment for up to ${apartments[3].capacity.max} guests, with 2 bedrooms, a living kitchen, bathroom, balcony, and separate toilet.`
},
thumbnail: '/apartments/2/7.webp',
highlights: [
capacity(3),
price(3)
],
images: [
'/apartments/3/1.webp',
'/apartments/3/2.webp',
'/apartments/3/3.webp',
'/apartments/3/4.webp',
'/apartments/3/5.webp',
'/apartments/3/6.webp',
'/apartments/3/7.webp',
'/apartments/3/8.webp',
'/apartments/3/9.webp',
'/apartments/3/10.webp',
'/apartments/3/11.webp',
'/apartments/3/12.webp',
'/apartments/3/13.webp',
'/apartments/3/14.webp'
],
features: [
{
icon: 'coins',
label: price(3)
},
{
icon: 'users',
label: capacity(3)
},
{
icon: 'scaling',
label: `${apartments[3].size}`
},
{
icon: 'mountain',
label: {
de: 'Balkon mit idyllischem Ausblick',
en: 'Balcony with idyllic view'
}
},
{
icon: 'bed-double',
label: {
de: 'Schlafzimmer 1 mit Doppelbett',
en: 'Bedroom 1 with double bed'
}
},
{
icon: 'bed-double',
label: {
de: 'Schlafzimmer 2 mit Doppelbett & Schlafsofa',
en: 'Bedroom 2 with double bed and sofa bed'
}
},
{
icon: 'sofa',
label: {
de: 'Wohnküche',
en: 'Living/kitchen area'
}
},
...apartment_features
]
}
]
}
}
})

View File

@ -5,15 +5,15 @@
</template>
<script setup lang="ts">
const { c } = useContent()
const { l } = useContentInjected()
const { add } = useToast()
const shown = useCookie('cookie-toast-shown', { maxAge: 60 * 60 * 24 * 7 })
onMounted(() => {
if (!shown.value) {
add({
title: c.value.cookies.title,
description: c.value.cookies.description,
title: l.value.cookies.title,
description: l.value.cookies.description,
timeout: 8000,
color: 'primary',
icon: 'i-heroicons-shield-check',

View File

@ -0,0 +1,7 @@
cookies:
title:
$de: Ihre Privatsphäre ist uns wichtig
$en: We value your privacy
description:
$de: Wir verwenden keine Tracking-Cookies. Genießen Sie Ihren Aufenthalt auf unserer Website!
$en: We do not use any tracking cookies. Enjoy your stay on our website!

View File

@ -1,5 +0,0 @@
<template>
<div>
This is an auto-imported component
</div>
</template>

View File

@ -36,7 +36,7 @@
variant="outline"
trailing-icon="i-heroicons-envelope"
>
{{ c.button.contact }}
{{ g.button.contact }}
</UButton>
<UButton
@ -46,7 +46,7 @@
variant="solid"
trailing-icon="i-heroicons-calendar-days"
>
{{ c.button.book }}
{{ g.button.book }}
</UButton>
</div>
</div>
@ -56,6 +56,6 @@
</template>
<script setup lang="ts">
const {p, c} = useContent()
defineProps<{ apartment: Apartment }>()
const {g, p} = useContentInjected()
defineProps<{ apartment: Apartment, index: number }>()
</script>

View File

@ -7,11 +7,11 @@
gap-4 py-4 border-b border-neutral-300"
>
<!-- avatar + copy -->
<AppHero :src="c.footer.image"
<AppHero :src="l.image"
alt="Ihre Gastgeberin Monika"
:size="16"
:title="c.footer.questions"
:description="c.footer.prompt">
:title="l.questions"
:description="l.prompt">
<!-- Contact shortcuts -->
<div class="mt-2 space-y-1">
<!-- Phone (phone + WhatsApp icons) -->
@ -20,16 +20,16 @@
<UIcon name="i-heroicons-phone" class="w-4 h-4 text-sm text-neutral-600"/>
<!-- WhatsApp icon (any Iconify set you use) -->
<UIcon name="i-uil-whatsapp" class="w-4 h-4 text-sm text-neutral-600"/>
<a :href="`tel:${c.contact.phone.replace(/\s+/g, '')}`" class="hover:underline text-sm text-neutral-600">
{{ c.contact.phone }}
<a :href="`tel:${g.business.phone.replace(/\s+/g, '')}`" class="hover:underline text-sm text-neutral-600">
{{ g.business.phone }}
</a>
</div>
<!-- E-mail -->
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-envelope" class="w-4 h-4 text-sm text-neutral-600"/>
<a :href="`mailto:${c.contact.email}`" class="hover:underline text-sm text-neutral-600">
{{ c.contact.email }}
<a :href="`mailto:${g.business.email}`" class="hover:underline text-sm text-neutral-600">
{{ g.business.email }}
</a>
</div>
</div>
@ -43,7 +43,7 @@
variant="solid"
trailing-icon="i-heroicons-envelope"
>
{{ c.button.contact }}
{{ g.button.contact }}
</UButton>
<UButton
@ -53,7 +53,7 @@
variant="solid"
trailing-icon="i-heroicons-calendar-days"
>
{{ c.button.book }}
{{ g.button.book }}
</UButton>
</div>
@ -61,11 +61,11 @@
<!-- © line -->
<div class="pt-4 text-center text-sm text-neutral-600 flex flex-col py-4">
<span>&copy; {{ year }} Panoramablick Saalbach</span>
<span>&copy; {{ year }} {{ g.business.name }}</span>
<div>
<NuxtLink :to="p('legal', 'imprint')" class="underline ml-2">{{ c.footer.imprint }}</NuxtLink>
<NuxtLink :to="p('legal', 'privacy')" class="underline ml-2">{{ c.footer.privacy }}</NuxtLink>
<NuxtLink :to="p('legal', 'accessibility')" class="underline ml-2">{{ c.footer.accessibility }}</NuxtLink>
<NuxtLink :to="p('legal#imprint')" class="underline ml-2">{{ l.imprint }}</NuxtLink>
<NuxtLink :to="p('legal#privacy')" class="underline ml-2">{{ l.privacy }}</NuxtLink>
<NuxtLink :to="p('legal#accessibility')" class="underline ml-2">{{ l.accessibility }}</NuxtLink>
</div>
</div>
</AppStripe>
@ -73,6 +73,6 @@
</template>
<script setup lang="ts">
const {p, c} = useContent()
const {g, l, p} = useContentInjected()
const year = new Date().getFullYear()
</script>

View File

@ -0,0 +1,16 @@
image: "/host.webp"
questions:
$de: Fragen oder Wünsche?
$en: Questions or requests?
prompt:
$de: Monika freut sich von Ihnen zu hören!
$en: Monika is looking forward to hearing from you!
imprint:
$de: Impressum
$en: Imprint
privacy:
$de: Datenschutz
$en: Privacy
accessibility:
$de: Barrierefreiheit
$en: Accessibility

View File

@ -3,11 +3,11 @@
<AppStripe>
<nav class="mx-auto py-4 flex items-center justify-between">
<!-- your logo / home link -->
<NuxtLink :to="p('/')" class="text-xl font-semibold flex items-center gap-2">
<NuxtLink :to="p('[locale]/[variant]')" class="text-xl font-semibold flex items-center gap-2">
<UIcon name="i-heroicons-home" />
<span class="hidden md:inline">
{{ c.header.home }}
{{ l.home }}
</span>
</NuxtLink>
@ -20,7 +20,7 @@
:to="p('apartments')"
class="block sm:hidden"
>
{{ c.header.apartments.split(' ')[0] }}
{{ l.apartments.split(' ')[0] }}
</NuxtLink>
<!-- Desktop: Show full text -->
@ -28,14 +28,14 @@
:to="p('apartments')"
class="hidden sm:block"
>
{{ c.header.apartments }}
{{ l.apartments }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="p('book')">{{ c.header.book }}</NuxtLink>
<NuxtLink :to="p('book')">{{ l.book }}</NuxtLink>
</li>
<li>
<NuxtLink :to="p('contact')">{{ c.header.contact }}</NuxtLink>
<NuxtLink :to="p('contact')">{{ l.contact }}</NuxtLink>
</li>
<LocaleSwitcher/>
<VariantSwitcher/>
@ -46,5 +46,5 @@
</template>
<script setup lang="ts">
const {p, c} = useContent()
const {l, p} = useContentInjected()
</script>

View File

@ -0,0 +1,10 @@
home: ${business.name}
apartments:
$de: Apartments & Preise
$en: Apartments & Prices
book:
$de: Buchen
$en: Book
contact:
$de: Kontakt
$en: Contact

View File

@ -0,0 +1,7 @@
<template>
<AppFeaturesGrid :features="l"/>
</template>
<script setup lang="ts">
const {l} = useContentInjected()
</script>

View File

@ -0,0 +1,96 @@
- icon: heart-handshake
label:
$de: Herzliche Gastgeber
$en: Warm-hearted hosts
detail:
$de: Persönliche Tipps & herzlicher Service direkt im Haus
$en: Personalized tips & warm service right in the house
- icon: heater
label:
$de: Sauna im Haus
$en: Sauna available
detail:
$de: Sauna inklusive Infrarotkabine und Dampfsauna
$en: Sauna including infrared cabin and steam sauna
- icon: wifi
label:
$de: Schnelles WLAN
$en: Fast WiFi
detail:
$de: Professionelles WLAN Equipment in jedem Appartement
$en: Professional Wi-Fi equipment in every apartment
- icon: car-front
label:
$de: Kostenlos parken
$en: Free parking
detail:
$de: Kostenlose Stellplätze direkt vor der Tür
$en: Free parking spaces right outside the door
- icon:
$summer: bike
$winter: lock
label:
$de$summer: Bikekeller
$de$winter: Skikeller
$en$summer: Bike storage room
$en$winter: Ski storage room
detail:
$de: Platz für Ihr Urlaubsequipment
$en: Space for your holiday equipment
- icon: flame-kindling
label:
$de: Grillplatz
$en: Barbecue
detail:
$de: Kugelgrill und Smoker leihbar
$en: Kettle grill and smoker available for rent
- icon: mountain
label:
$de: Exzellente Lage
$en: Excellent location
detail:
$de: 5 Min. zum Ortskern
$en: 5 minutes to the town center
- icon: cable-car
label:
$de$summer: Bei den Gondeln
$de$winter: An der Skipiste
$en$summer: Right next to the gondolas
$en$winter: Ski-in/ski-out location
detail:
$de$summer: Direkter Einstieg in den Bikepark Saalbach
$de$winter: Direkter Einstieg in den Skizirkus Saalbach
$en$summer: Direct access to the Saalbach bike park
$en$winter: The house is located next to the ski slope
- icon: id-card-lanyard
label:
$summer: Joker Card
$winter: Guest Mobility Ticket
detail:
$de$summer: Kostenlose Aktivitäten von der ersten bis zur letzten Urlaubsminute inklusive!
$de$winter: Kostenlose Fahrten mit öffentlichen Verkehrsmitteln im Bundesland Salzburg
$en$summer: Free activities included from the first to the last minute of your vacation
$en$winter: Free rides on public transportation throughout Salzburg
- icon: bed
label:
$de: Erholsamer Schlaf
$en: Restful sleep
detail:
$de$summer: Angenehme Zimmertemperaturen auch an heißen Sommertagen
$de$winter: Kuschelige Wohlfühlbetten für einen erholsamen Schlaf
$en$summer: Comfortable room temperatures even on hot summer days
$en$winter: Cozy comfort beds for a restful sleep
- icon: trees
label:
$de: Atemberaubendes Panorama
$en: Breathtaking panorama
detail:
$de: Genießen Sie die Aussicht über das Home of Lässig
$en: Enjoy the wonderful view over the Home of Lässig
- icon: store
label:
$de: Sportgeschäft
$en: Sports shop
detail:
$de: Exklusive Rabatte bei Sport Bründl bei Vorabreservierung
$en: Exclusive discounts at Sport Bründl with advance reservation

View File

@ -1,22 +0,0 @@
// ~/composables/useApartments.ts
import {computed} from 'vue' // ① explizit importieren
import {useI18n} from 'apps/panoramablick-saalbach.at/.nuxt/imports'
export interface Apartment {
title: string
subtitle: string
images: string[]
features: { icon: string; label: string }[]
}
export function useApartments() {
const {t} = useI18n()
const list = computed(() =>
t('apartments') as unknown as Apartment[]
)
const lst = list//[1, 2, 3]
return {lst}
}

View File

@ -0,0 +1,26 @@
export function useSeoLinking() {
const config = useContentConfig()
const locales = config.locale?.list.map(l => l.code) ?? []
const {locale} = useContentInjected()
const route = useRoute()
const base = 'https://panoramablick-saalbach.at'
const links = computed(() => {
const links = []
for (const l of locales) {
const path = '/' + l + route.path.slice(3)
links.push({rel: 'alternate', hreflang: l, href: base + path})
if (l === locale.value) {
links.push({rel: 'canonical', href: base + path})
}
}
return links
})
useHead(() => ({link: links.value}))
}

View File

@ -0,0 +1,47 @@
class Text {
constructor(readonly $de: string, readonly $en: string) {}
}
class Price {
readonly text: Text
constructor(readonly from: number) {
this.text = new Text(
`Ab ${this.from},- pro Tag`,
`Starting at ${this.from},- per day`,
)
}
}
class Capacity {
readonly text: Text
constructor(readonly from: number, readonly to: number) {
this.text = new Text(
`${this.from} bis ${this.to} Personen`,
`${this.from} to ${this.to} persons`,
)
}
}
class Apartment {
readonly id: string
readonly title: string
readonly price: Price
readonly capacity: Capacity
constructor(number: number, readonly size: number, price: number, from: number, to: number) {
this.id = `apartment-${number}`
this.title = `Apartment ${number}`
this.price = new Price(price)
this.capacity = new Capacity(from, to)
}
}
export default {
apartments: [
new Apartment(1, 38, 98, 2, 4),
new Apartment(2, 50, 155, 2, 5),
new Apartment(3, 50, 155, 2, 6)
]
}

View File

@ -0,0 +1,30 @@
business:
uid: panoramablick-saalbach.at
name:
$de: Landhaus Panoramablick
$en: Guesthouse Panoramablick
operator: Monika & Norbert Pail
address: Unterer Ronachweg 731, 5753 Saalbach, AT
phone: +43 664 7904775
email: info@panoramablick-saalbach.at
vat: ATU 62062608
authority: Bezirkshauptmannschaft Zell am See
membership: Wirtschaftskammer Salzburg
regulation: www.ris.bka.gv.at
button:
apartments:
$de: Apartments
$en: Apartments
book:
$de: Buchen
$en: Book
contact:
$de: Kontakt
$en: Contact
hosts:
$de: Die Gastgeber
$en: The Hosts
map:
$de: Maps Öffnen
$en: Open Maps

View File

@ -29,12 +29,5 @@ export default defineNuxtConfig({
icon: {
provider: 'server',
serverBundle: 'local'
},
nitro: {
prerender: {
crawlLinks: true,
routes: ["/de/summer"],
failOnError: true
}
}
})

View File

@ -14,6 +14,7 @@
"@nuxt/fonts": "^0.11.4",
"@nuxt/icon": "^2.0.0",
"@nuxt/image": "1.11.0",
"@nuxt/kit": "^4.1.3",
"@nuxt/ui": "^4.0.1",
"nuxt": "^4.1.2",
"tailwindcss": "^4.1.14",

View File

@ -0,0 +1,127 @@
class Text {
constructor(readonly $de: string, readonly $en: string) {
}
}
class Feature {
readonly label: Text | string
constructor(readonly icon: string, readonly de: string, readonly en?: string) {
this.label = en ? new Text(de, en) : de
}
}
const features = [
new Feature('shower-head', 'Badezimmer mit Dusche', 'Bathroom with shower'),
new Feature('toilet', 'Bad und WC getrennt', 'Bathroom and toilet in separate rooms'),
new Feature('wifi', 'Schnelles WLAN', 'High-speed WiFi'),
new Feature('tv', 'Fernseher', 'TV'),
new Feature('microwave', 'Mikrowelle', 'Microwave'),
new Feature('microwave', 'Backrohr', 'Oven'),
new Feature('bubbles', 'Geschirrspüler', 'Dishwasher'),
new Feature('coffee', 'Kaffeemaschine', 'Coffee maker'),
new Feature('coffee', 'Wasserkocher', 'Electric water boiler'),
new Feature('bed', 'Bettwäsche', 'Bed sheets'),
new Feature('layers', 'Handtücher', 'Towels'),
new Feature('wind', 'Haarföhn', 'Hairdryer')
]
class Apartment {
readonly title: string
readonly subtitle: Text
readonly features: Feature[]
constructor(index: number, readonly de: string, readonly en: string, specifics: Feature[], readonly images: string[]) {
this.title = '${apartments[' + index.toString() + '].title}'
this.subtitle = new Text(de, en)
this.features = [
new Feature('coins', '${apartments[' + index.toString() + '].price.text}'),
new Feature('users', '${apartments[' + index.toString() + '].capacity.text}'),
new Feature('scaling', '${apartments[' + index.toString() + '].size} m²'),
...specifics,
...features
]
}
}
export default {
list: [
new Apartment(
0,
'${apartments[0].size} m² Urlaubsvergnügen! Dieses gemütliche Appartement bietet Ihnen Platz für bis zu ${apartments[0].capacity.to} Personen. Freuen Sie sich auf 1 Schlafzimmer, eine Wohnküche mit ausziehbarer Couch, Badezimmer, eine kleine Terrasse und ein getrenntes WC.',
'${apartments[0].size} m² of holiday comfort! This cozy apartment comfortably accommodates up to ${apartments[0].capacity.to} guests. It features one bedroom, an open-plan kitchen and living area with a pull-out sofa, a bathroom, a small terrace, and a separate toilet.',
[
new Feature('mountain', 'Terrasse mit idyllischem Ausblick', 'Terrace with idyllic view'),
new Feature('bed-double', 'Schlafzimmer mit Doppelbett', 'Bedroom with double bed'),
new Feature('sofa', 'Wohnküche mit ausziehbarem Schlafsofa', 'Living/kitchen area with sofa bed'),
],
[
'/apartments/1/1.webp',
'/apartments/1/2.webp',
'/apartments/1/3.webp',
'/apartments/1/4.webp',
'/apartments/1/5.webp',
'/apartments/1/6.webp',
'/apartments/1/7.webp',
'/apartments/1/8.webp',
'/apartments/1/9.webp',
'/apartments/1/10.webp',
'/apartments/1/11.webp'
]
),
new Apartment(
1,
'Wohnkomfort auf ${apartments[1].size} m²! Dieses großzügige Appartement bietet Ihnen Platz für bis zu ${apartments[1].capacity.to} Personen und bietet 2 Schlafzimmer, eine Wohnküche, Badezimmer, einen Balkon und ein getrenntes WC.',
'Enjoy ${apartments[1].size} m² of comfortable living! This large apartment sleeps up to ${apartments[1].capacity.to} people and includes 2 bedrooms, a combined kitchen and living room, a bathroom, a balcony, and a separate toilet.',
[
new Feature('mountain', 'Balkon mit idyllischem Ausblick', 'Balcony with idyllic view'),
new Feature('bed-double', 'Schlafzimmer 1 mit Doppelbett', 'Bedroom 1 with double bed'),
new Feature('bed-double', 'Schlafzimmer 2 mit Doppelbett & Schlafsofa', 'Bedroom 2 with double bed & sofa bed'),
new Feature('sofa', 'Wohnküche', 'Living/kitchen area'),
],
[
'/apartments/2/1.webp',
'/apartments/2/2.webp',
'/apartments/2/3.webp',
'/apartments/2/4.webp',
'/apartments/2/5.webp',
'/apartments/2/6.webp',
'/apartments/2/7.webp',
'/apartments/2/8.webp',
'/apartments/2/9.webp',
'/apartments/2/10.webp',
'/apartments/2/11.webp',
'/apartments/2/12.webp',
'/apartments/2/13.webp'
]
),
new Apartment(
2,
'Wohlfühlen auf ${apartments[2].size} m²! Dieses geräumige Appartement bietet Ihnen Platz für bis zu ${apartments[2].capacity.to} Personen und bietet 2 Schlafzimmer, eine Wohnküche, Badezimmer, einen Balkon und ein getrenntes WC.',
'Spacious ${apartments[2].size} m² apartment for up to ${apartments[2].capacity.to} guests, with 2 bedrooms, a living kitchen, bathroom, balcony, and separate toilet.',
[
new Feature('mountain', 'Balkon mit idyllischem Ausblick', 'Balcony with idyllic view'),
new Feature('bed-double', 'Schlafzimmer 1 mit Doppelbett', 'Bedroom 1 with double bed'),
new Feature('bed-double', 'Schlafzimmer 2 mit Doppelbett & Schlafsofa', 'Bedroom 2 with double bed & sofa bed'),
new Feature('sofa', 'Wohnküche', 'Living/kitchen area'),
],
[
'/apartments/3/1.webp',
'/apartments/3/2.webp',
'/apartments/3/3.webp',
'/apartments/3/4.webp',
'/apartments/3/5.webp',
'/apartments/3/6.webp',
'/apartments/3/7.webp',
'/apartments/3/8.webp',
'/apartments/3/9.webp',
'/apartments/3/10.webp',
'/apartments/3/11.webp',
'/apartments/3/12.webp',
'/apartments/3/13.webp',
'/apartments/3/14.webp'
]
)
]
}

View File

@ -4,27 +4,33 @@
<div class="flex flex-col md:flex-row items-center gap-8">
<!-- Left: Image Group -->
<div class="flex flex-row gap-4 justify-center relative">
<NuxtImg :src="c.apartments.highlight.image.left" alt="Image 1" class="w-40 h-60 object-cover"/>
<NuxtImg :src="c.apartments.highlight.image.right" class="w-40 h-60 object-cover mt-6" />
<NuxtImg :src="l.highlight.image.left" alt="Image 1" class="w-40 h-60 object-cover"/>
<NuxtImg :src="l.highlight.image.right" class="w-40 h-60 object-cover mt-6" />
</div>
<AppTitleText :title="c.apartments.highlight.title" :text="c.apartments.highlight.description">
<AppFeaturesGrid :features="c.landing.features.list"/>
<AppTitleText :title="l.highlight.title" :text="l.highlight.description">
<AppHighlights/>
</AppTitleText>
</div>
</AppFlatSection>
<AppCardSection
v-for="(apartment, index) in c.apartments.list"
:key="apartment.id"
v-for="(apartment, index) in l.list"
:key="index"
:padTop="index === 0"
>
<AppApartment :apartment="apartment" :id="apartment.id"/>
<AppApartment :apartment="apartment" :index="index" :id="g.apartments[index].id"/>
</AppCardSection>
</div>
</template>
<script setup lang="ts">
const {c} = useContent()
useSeoLinking()
const {g, l} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -0,0 +1,23 @@
meta:
title:
$de$summer: ${business.name} Saalbach Unsere Sommer-Apartments
$de$winter: ${business.name} Saalbach Unsere Winter-Apartments
$en$summer: ${business.name} Saalbach Our Summer Apartments
$en$winter: ${business.name} Saalbach Our Winter Apartments
description:
$de$summer: Entdecken Sie unsere gemütlichen Sommer-Apartments in Saalbach mit Balkon oder Terrasse, WLAN, Sauna und Panoramablick unweit der Gondeln, ideal zum Biken.
$de$winter: Entdecken Sie unsere komfortablen Winter-Apartments in Saalbach mit Sauna, Skikeller, WLAN und Panoramablick unweit der Gondeln, ideal zum Skifahren.
$en$summer: Discover our cozy summer apartments in Saalbach with balcony or terrace, Wi-Fi, sauna, and mountain views not far from the gondolas, perfect for biking.
$en$winter: Discover our comfortable winter apartments in Saalbach with sauna, ski storage, Wi-Fi, and mountain views not far from the gondolas, perfect for skiing.
highlight:
title:
$de: Darauf können Sie sich freuen!
$en: Heres what you can look forward to!
description:
$de: Was Sie in Ihrem Urlaub im Landhaus Appartement Panoramablick erwartet.
$en: What to expect during your vacation at Landhaus Appartement Panoramablick.
image:
left: /ap.webp
right: /sauna.webp

View File

@ -4,7 +4,7 @@
<div class="absolute inset-0 z-0">
<UCarousel
v-slot="{ item }"
:items="c.landing.welcome.images"
:items="l.welcome.images"
:ui="{item: 'basis-full h-full ps-0', container: 'flex items-stretch h-full'}"
:autoplay="{ delay: 5000 }"
:loop="true"
@ -24,28 +24,28 @@
<div class="flex flex-col md:flex-row py-6 gap-4 sm:gap-8 lg:gap-16">
<div class="flex flex-col">
<div>
<h1 class="text-5xl max-w-4xl font-bold">{{ c.landing.welcome.title }}</h1>
<h1 class="text-5xl max-w-4xl font-bold">{{ l.welcome.title }}</h1>
<p class="mt-4 text-lg">
{{
c.landing.welcome.description
l.welcome.description
}}
</p>
</div>
<div class="mt-8 flex gap-4 flex-wrap">
<UButton :to="p('apartments')" color="primary" variant="solid" size="xl"
trailing-icon="i-heroicons-arrow-right">{{ c.button.apartments }}
trailing-icon="i-heroicons-arrow-right">{{ g.button.apartments }}
</UButton>
<UButton :to="p('book')" color="secondary" variant="solid" size="xl"
trailing-icon="i-heroicons-calendar-days">
{{ c.button.book }}
{{ g.button.book }}
</UButton>
<UButton :to="p('contact')" color="secondary" variant="solid" size="xl"
trailing-icon="i-heroicons-envelope">
{{ c.button.contact }}
{{ g.button.contact }}
</UButton>
</div>
</div>
<div v-if="c.landing.welcome.joker === true" class="flex items-center justify-center p-8">
<div v-if="l.welcome.joker === true" class="flex items-center justify-center p-8">
<a href="https://www.saalbach.com/de/sommer/joker-card">
<NuxtImg
src="/joker-card.webp"
@ -64,16 +64,16 @@
<div class="flex flex-col md:flex-row items-center gap-4">
<!-- Left: Image Group -->
<div class="flex flex-row gap-4 justify-center relative">
<NuxtImg :src="c.landing.highlight.image.left" alt="Image 1" class="w-40 h-60 object-cover"/>
<NuxtImg :src="c.landing.highlight.image.right" class="w-40 h-60 object-cover mt-6" />
<NuxtImg :src="l.highlight.image.left" alt="Image 1" class="w-40 h-60 object-cover"/>
<NuxtImg :src="l.highlight.image.right" class="w-40 h-60 object-cover mt-6" />
</div>
<!-- Right: Text Content -->
<AppTitleText :title="c.landing.highlight.title" :text="c.landing.highlight.description">
<UButton :to="p('contact', 'hosts')" color="primary" variant="solid" size="xl"
<AppTitleText :title="l.highlight.title" :text="l.highlight.description">
<UButton :to="p('contact#hosts')" color="primary" variant="solid" size="xl"
trailing-icon="i-heroicons-arrow-right">
{{
c.button.hosts
g.button.hosts
}}
</UButton>
</AppTitleText>
@ -84,10 +84,10 @@
<div class="flex flex-col md:flex-row w-full items-stretch overflow-hidden">
<!-- Left: 40% width on desktop, full on mobile -->
<div class="w-full md:w-[40%] flex flex-col justify-center pr">
<AppTitleText :title="c.landing.location.title" :text="c.landing.location.description">
<AppTitleText :title="l.location.title" :text="l.location.description">
<UButton to="https://maps.app.goo.gl/FtTC8ervoBCnfU3j7" color="primary" variant="solid" size="xl"
trailing-icon="i-heroicons-arrow-right">
{{ c.button.map }}
{{ g.button.map }}
</UButton>
</AppTitleText>
</div>
@ -98,7 +98,7 @@
aria-label="Google Maps öffnen"
>
<NuxtImg
:src="c.landing.location.image"
:src="l.location.image"
alt="Vorschaubild der Lage in Google Maps"
class="w-full h-64 md:h-full object-cover transition-transform duration-300 ease-out group-hover:scale-110"
/>
@ -109,20 +109,20 @@
</AppCardSection>
<AppFlatSection>
<AppTitleText :title="c.landing.apartments.title" :text="c.landing.apartments.description">
<AppTitleText :title="l.apartments.title" :text="l.apartments.description">
<div class="flex flex-wrap gap-4 justify-center">
<NuxtLink v-for="apartment in c.apartments.list" :to="p('apartments', apartment.id)"
<NuxtLink v-for="(thumbnail, index) in l.apartments.thumbnails" :to="p('apartments#' + g.apartments[index].id)"
class="relative group block w-60 h-80 overflow-hidden">
<NuxtImg :src="apartment.thumbnail" alt="Image 1"
<NuxtImg :src="thumbnail" alt="Image 1"
class="w-full h-full object-cover transition-transform duration-300 ease-out group-hover:scale-110"/>
<div class="absolute inset-0 bg-black/50"></div>
<div
class="absolute inset-0 flex items-center justify-center text-white text-lg font-semibold drop-shadow-md">
<div class="text-center">
<h2>{{ apartment.title }}</h2>
<ul>
<li v-for="highlight in apartment.highlights" class="text-sm">{{ highlight }}</li>
<li class="text-sm">{{ g.apartments[index].title }}</li>
<li class="text-sm">{{ g.apartments[index].capacity.text }}</li>
<li class="text-sm">{{ g.apartments[index].price.text }}</li>
</ul>
</div>
</div>
@ -134,19 +134,19 @@
<AppCardSection>
<div class="flex-1 pr">
<h2 class="text-2xl font-bold mb-4">
{{ c.landing.features.title }}
{{ l.features.title }}
</h2>
<p class="text-lg pb-4">
{{ c.landing.features.description1 }}
{{ l.features.description1 }}
<UButton to="https://www.bruendl.at/" variant="outline" trailing-icon="i-heroicons-arrow-right">{{
c.landing.features.description2
l.features.description2
}}
</UButton>
{{ c.landing.features.description3 }}
{{ l.features.description3 }}
</p>
<AppFeaturesGrid :features="c.landing.features.list"/>
<AppHighlights/>
</div>
</AppCardSection>
</div>
@ -160,7 +160,11 @@
</style>
<script setup lang="ts">
const {p, c} = useContent()
useSeoLinking()
const {g, l, p} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>
<script setup lang="ts">
</script>

View File

@ -0,0 +1,83 @@
meta:
title:
$de$summer: ${business.name} Saalbach Apartments für Ihren Sommerurlaub
$de$winter: ${business.name} Saalbach Apartments für Ihren Winterurlaub
$en$summer: ${business.name} Saalbach Apartments for Your Summer Holiday
$en$winter: ${business.name} Saalbach Apartments for Your Winter Holiday
description:
$de$summer: Gemütliche Apartments in Saalbach mit Panoramablick. Genießen Sie Wandern, Radfahren, Sauna und herzliche Gastfreundschaft im Sommerurlaub.
$de$winter: Gemütliche Apartments in bester Lage in Saalbach. Genießen Sie Ihren Winterurlaub mit Sauna, Panoramablick und herzlicher Gastfreundschaft.
$en$summer: Cozy apartments in Saalbach with panoramic mountain views. Enjoy hiking, biking, the sauna, and warm hospitality during your summer stay.
$en$winter: Cozy apartments in the best location in Saalbach. Enjoy your winter holiday with a sauna, panoramic mountain views, and warm hospitality.
welcome:
title:
$de: Willkommen im ${business.name} in Saalbach
$en: Welcome to ${business.name} in Saalbach
description:
$de: Genießen Sie erholsame Tage inmitten der Salzburger Natur mit herrlichem Panoramablick auf die Berge von Saalbach. Unser Landhaus bietet gemütliche Apartments und herzliche Gastfreundschaft in perfekter Lage.
$en: Enjoy relaxing days amidst the Salzburg nature with a stunning panoramic view of the Saalbach mountains. Our guesthouse offers cozy apartments and warm hospitality in a perfect location.
images:
$summer:
- /landing/2.webp
- /landing/1.webp
- /landing/3.webp
- /landing/4.webp
$winter:
- /landing/w1.webp
- /landing/w2.webp
joker:
$summer: true
$winter: false
highlight:
title:
$de: Erleben Sie Natur hautnah
$en: Experience nature close up
description:
$de$summer: Unser Landhaus liegt eingebettet in der idyllischen Salzburger Landschaft mit atemberaubendem Panoramablick auf Saalbach. Ob Wandern, Radfahren, die Umgebung erkunden, oder Entspannte Momente in der Sauna bei uns finden Sie alles, was das Sommerherz begehrt.
$de$winter: Unser Landhaus liegt eingebettet in der idyllischen Salzburger Landschaft mit atemberaubendem Panoramablick auf Saalbach. Ob Schifahren, Winterwandern, die Umgebung erkunden, oder Entspannte Momente in der Sauna bei uns finden Sie alles, was das Winterherz begehrt.
$en$summer: Our guesthouse is located in the idyllic Salzburg countryside with breathtaking panoramic views of Saalbach. Whether hiking, cycling, exploring the surroundings, or relaxing moments in the sauna here youll find everything your heart desires.
$en$winter: Our guesthouse is located in the idyllic Salzburg countryside with breathtaking panoramic views of Saalbach. Whether skiing, winter hiking, exploring the surroundings, or enjoying relaxing moments in the sauna here youll find everything your heart desires.
image:
left: /ap.webp
right: /sauna.webp
location:
title:
$de: Logieren Sie in bester Lage
$en: Stay at the best location
description:
$de$summer: Unser Landhaus liegt mitten in Saalbach. In wenigen Gehminuten sind Sie bei den Gondeln, im Bikepark, sowie im wunderschönen Ortskern von Saalbach. Lassen Sie Ihr Auto den Urlaub über stehen und genießen Sie die Zeit bei uns.
$de$winter: Unser Landhaus liegt mitten in Saalbach. In wenigen Gehminuten sind Sie an der Skipiste sowie im wunderschönen Ortskern von Saalbach. Lassen Sie Ihr Auto den Urlaub über stehen und genießen Sie die Zeit bei uns.
$en$summer: Our guesthouse is located right in the heart of Saalbach. Within a few minutes' walk, you'll reach the gondolas, the bike park, and the beautiful town center of Saalbach. Leave your car parked for the entire vacation and enjoy your time with us.
$en$winter: Our guesthouse is located right in the heart of Saalbach. Within a few minutes' walk, you'll be at the ski slope and the beautiful town center of Saalbach. Leave your car parked for the entire vacation and enjoy your time with us.
image: /maps.webp
apartments:
title:
$de: Unsere Apartments
$en: Our apartments
description:
$de$summer: Wählen Sie aus unseren neu renovierten Appartements. Die Wohnungen sind großzügig gestaltet und bieten alle einen herrlichen Blick in die Saalbacher Berglandschaft. Dank einer raffinierter Bauweise genießen Sie auch an den heißesten Sommerabenden angenehme Temperaturen in unseren Zimmern, ganz ohne Klimaanlage. Konstante Wohlfühltemperaturen laden zum Durchschlafen und Erholen ein.
$de$winter: Wählen Sie aus unseren neu renovierten Appartements. Die Wohnungen sind großzügig gestaltet und bieten alle einen herrlichen Blick in die Saalbacher Berglandschaft. Freuen Sie sich auf gemütlich warme Winterabende in unseren drei Wohlfühlappartements.
$en$summer: Choose from our newly renovated apartments. The units are spaciously designed and all offer a wonderful view of the Saalbach mountain landscape. Thanks to clever construction, you can enjoy comfortable temperatures in our rooms even on the hottest summer evenings—completely without air conditioning. Consistent, pleasant temperatures invite you to sleep well and relax.
$en$winter: Choose from our newly renovated apartments. The units are spaciously designed and all offer a wonderful view of the Saalbach mountain landscape. Look forward to cozy, warm winter evenings in our three comfort apartments.
thumbnails:
- /apartments/2/4.webp
- /apartments/2/8.webp
- /apartments/2/7.webp
features:
title:
$de: Darauf können Sie sich freuen!
$en: Thats something to look forward to!
description1:
$de: 'Was Sie in Ihrem Urlaub im Landhaus Appartement Panoramablick erwartet. Unser Tipp:'
$en: 'What awaits you during your stay at Landhaus Appartement Panoramablick. Our insider tip:'
description2:
$de: Reservieren
$en: Book
description3:
$de: Sie online beim Sportgeschäft in nächster Nähe und sichern sich dabei exklusive Rabatte.
$en: online at the nearby sports shop and secure exclusive discounts.

View File

@ -27,7 +27,7 @@
<div>
<AppFlatSection>
<div class="flex flex-col items-center justify-center">
<span class="text-lg text-neutral-600">Coming Soon</span>
<span class="text-lg text-neutral-600">{{l.coming}}</span>
</div>
<!-- &lt;!&ndash; SSR-safe: iframe appears only in the browser &ndash;&gt;-->
@ -43,5 +43,13 @@
</AppFlatSection>
</div>
</template>
<script setup lang="ts">
useSeoLinking()
const {l} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -0,0 +1,12 @@
meta:
title:
$de: ${business.name} Saalbach Online Buchen & Anfragen
$en: ${business.name} Saalbach Online Booking & Inquiries
description:
$de: Buchen Sie Ihren Urlaub im ${business.name} Saalbach bald ganz einfach online. Bis dahin erreichen Sie uns über das Kontaktformular für Ihre Anfrage.
$en: Soon youll be able to book your stay at ${business.name} Saalbach online. Until then, please contact us via the inquiry form.
coming:
$de: Demnächst verfügbar! Bitte verwenden Sie vorerst das Kontaktformular.
$en: Coming soon! Please use the contact form for now.

View File

@ -6,11 +6,11 @@
<div class="flex flex-col md:flex-row gap-8 items-center">
<!-- Contact form -->
<div class="w-full md:w-auto max-w-xl mx-auto lg:mx-0">
<h1 class="text-3xl sm:text-4xl font-bold mb-4">{{ c.contact.title }}</h1>
<h1 class="text-3xl sm:text-4xl font-bold mb-4">{{ l.title }}</h1>
<!-- Extended intro -->
<p class="text-neutral-600 mb-4 max-w-prose">
{{ c.contact.description }}
{{ l.description }}
</p>
<!-- Contact shortcuts -->
@ -21,51 +21,51 @@
<UIcon name="i-heroicons-phone" class="w-4 h-4 text-neutral-600"/>
<!-- WhatsApp icon (any Iconify set you use) -->
<UIcon name="i-uil-whatsapp" class="w-4 h-4 text-neutral-600"/>
<a :href="`tel:${c.contact.phone.replace(/\s+/g, '')}`" class="hover:underline text-neutral-600">
{{ c.contact.phone }}
<a :href="`tel:${g.business.phone.replace(/\s+/g, '')}`" class="hover:underline text-neutral-600">
{{ g.business.phone }}
</a>
</div>
<!-- E-mail -->
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-envelope" class="w-4 h-4 text-neutral-600"/>
<a :href="`mailto:${c.contact.email}`" class="hover:underline text-neutral-600">
{{ c.contact.email }}
<a :href="`mailto:${g.business.email}`" class="hover:underline text-neutral-600">
{{ g.business.email }}
</a>
</div>
</div>
<p class="text-neutral-600 mb-4 max-w-prose">
{{ c.contact.online1 }}
{{ l.online1 }}
<UButton :to="p('book')" variant="outline" trailing-icon="i-heroicons-calendar-days">{{
c.contact.online2
l.online2
}}
</UButton>
{{ c.contact.online3 }}
{{ l.online3 }}
</p>
<!-- Form -->
<UForm :state="state" :schema="schema" class="space-y-4" @submit="onSubmit">
<UFormField name="name" label="Name" :ui="{ label: 'sr-only' }">
<UInput v-model="state.name" class="w-full" :placeholder="c.contact.form.name.prompt"/>
<UInput v-model="state.name" class="w-full" :placeholder="l.form.name.prompt"/>
</UFormField>
<UFormField name="email" label="E-Mail" :ui="{ label: 'sr-only' }">
<UInput v-model="state.email" type="email" class="w-full"
:placeholder="c.contact.form.email.prompt"/>
:placeholder="l.form.email.prompt"/>
</UFormField>
<UFormField name="subject" label="Betreff" :ui="{ label: 'sr-only' }">
<UInput v-model="state.subject" class="w-full" :placeholder="c.contact.form.subject.prompt"/>
<UInput v-model="state.subject" class="w-full" :placeholder="l.form.subject.prompt"/>
</UFormField>
<UFormField name="message" label="Nachricht" :ui="{ label: 'sr-only' }">
<UTextarea v-model="state.message" :rows="6" class="w-full"
:placeholder="c.contact.form.message.prompt"/>
:placeholder="l.form.message.prompt"/>
</UFormField>
<UButton type="submit" color="primary" size="lg" class="w-full sm:w-auto" trailing-icon="i-heroicons-paper-airplane">
{{ c.contact.form.send }}
{{ l.form.send }}
</UButton>
</UForm>
@ -74,21 +74,21 @@
<!-- Decorative host snapshots -->
<div id="hosts" class="flex flex-col gap-10 lg:self-center w-full md:w-auto">
<AppHero
:src="c.contact.heroes.parents.image"
:alt="c.contact.heroes.parents.title"
:src="l.heroes.parents.image"
:alt="l.heroes.parents.title"
image-side="left"
:size="50"
:title="c.contact.heroes.parents.title"
:description="c.contact.heroes.parents.description"
:title="l.heroes.parents.title"
:description="l.heroes.parents.description"
/>
<AppHero
:src="c.contact.heroes.children.image"
:alt="c.contact.heroes.children.title"
:src="l.heroes.children.image"
:alt="l.heroes.children.title"
image-side="right"
:size="50"
:title="c.contact.heroes.children.title"
:description="c.contact.heroes.children.description"
:title="l.heroes.children.title"
:description="l.heroes.children.description"
/>
</div>
</div>
@ -97,17 +97,25 @@
</template>
<script setup lang="ts">
useSeoLinking()
const {g, l, p} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
/*TODO form should contain link to privacy statement?*/
const {p, c} = useContent()
import * as v from 'valibot'
import type {FormSubmitEvent} from '@nuxt/ui'
/* ───── validation schema ───── */
const schema = computed(() => v.object({
name: v.pipe(v.string(), v.minLength(2, c.value.contact.form.name.invalid)),
email: v.pipe(v.string(), v.email(c.value.contact.form.email.invalid)),
subject: v.pipe(v.string(), v.minLength(3, c.value.contact.form.subject.invalid)),
message: v.pipe(v.string(), v.minLength(10, c.value.contact.form.message.invalid))
name: v.pipe(v.string(), v.minLength(2, l.value.form.name.invalid)),
email: v.pipe(v.string(), v.email(l.value.form.email.invalid)),
subject: v.pipe(v.string(), v.minLength(3, l.value.form.subject.invalid)),
message: v.pipe(v.string(), v.minLength(10, l.value.form.message.invalid))
}))
//type Schema = v.InferOutput<typeof schema>
@ -120,8 +128,7 @@ const toast = useToast()
/* ───── submit handler ───── */
async function onSubmit(event: FormSubmitEvent<Schema>) {
const config = useAppConfig()
const hotelId = config.content.business.uid ?? ''
const hotelId = g.value.business.uid ?? ''
try {
await $fetch('https://api.dominikmilacher.com/contact', {
@ -134,8 +141,8 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
})
toast.add({
title: c.value.contact.form.sent.title,
description: c.value.contact.form.sent.description,
title: l.value.form.sent.title,
description: l.value.form.sent.description,
color: 'primary'
})
@ -145,11 +152,10 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
//console.log(err?.data?.detail)
//console.log(err?.message)
toast.add({
title: c.value.contact.form.error.title,
description: c.value.contact.form.error.description,
title: l.value.form.error.title,
description: l.value.form.error.description,
color: 'primary'
})
}
}
</script>

View File

@ -0,0 +1,82 @@
meta:
title:
$de: ${business.name} Saalbach Kontakt & Anfrage
$en: ${business.name} Saalbach Contact & Inquiry
description:
$de: Nehmen Sie Kontakt mit uns auf! Monika $ Norbert freuen sich auf Ihre Anfrage per Telefon, E-Mail oder Formular direkt im Landhaus Panoramablick.
$en: Get in touch with us! Monika $ Norbert look forward to your inquiry by phone, email, or contact form at Landhaus Panoramablick Saalbach.
title:
$de: Kontakt aufnehmen
$en: Contact us
description:
$de: Wir freuen uns auf Ihre Anfrage Sie erreichen uns per Telefon, SMS, WhatsApp, E-Mail oder über das untenstehende Formular.
$en: Were happy to hear from you contact us by phone, SMS, WhatsApp, email, or through the form below.
online1:
$de: Sie wollen direkt online buchen?
$en: Ready to book online?
online2:
$de: Hier
$en: Here
online3:
$de: geht's lang!
$en: you go!
form:
name:
prompt:
$de: Ihr Name
$en: Your name
invalid:
$de: Name zu kurz
$en: Name too short
email:
prompt:
$de: Ihre E-Mail
$en: Your email address
invalid:
$de: E-Mail Adresse nicht gültig
$en: Email address not valid
subject:
prompt:
$de: Betreff
$en: Subject
invalid:
$de: Betreff zu kurz
$en: Subject too short
message:
prompt:
$de: Ihre Anfrage
$en: Your inquiry
invalid:
$de: Nachricht zu kurz
$en: Message too short
send:
$de: Senden
$en: Send
sent:
title:
$de: Nachricht gesendet
$en: Message sent
description:
$de: Vielen Dank - wir melden uns bald.
$en: Thank you - we'll be in touch soon.
error:
title:
$de: Fehler
$en: Error
description:
$de: Die Nachricht konnte nicht versendet werden - bitte versuchen Sie es erneut.
$en: The message could not be sent - please try again.
heroes:
parents:
title: Monika & Norbert
description:
$de: Herzliche Gastgeber immer mit einem Tipp zur Region parat.
$en: Genuine hospitality and always a great tip for exploring the area.
image: /family/monika-norbert.webp
children:
title: Sabrina & Daniel
description:
$de: Jung, aktiv und voller Energie Sohn Daniel und Tochter Sabrina bringen Schwung ins Haus.
$en: Energetic and full of life Daniel and Sabrina keep the house buzzing.
image: /family/sabrina-daniel.webp

View File

@ -1,7 +1,7 @@
<template>
<div>
<AppFlatSection>
<section v-for="block in c.legal"
<section v-for="block in l.blocks"
:key="block.anchor"
:id="block.anchor"
class="prose mx-auto max-w-3xl p-4 lg:p-8">
@ -29,5 +29,10 @@
</template>
<script setup lang="ts">
const {c} = useContent()
useSeoLinking()
const {l} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -0,0 +1,146 @@
meta:
title:
$de: ${business.name} Saalbach Impressum & Datenschutz
$en: ${business.name} Saalbach Legal Notice & Privacy Policy
description:
$de: Rechtliche Informationen, Impressum und Datenschutzerklärung für das ${business.name} Saalbach.
$en: Legal information, imprint, and privacy policy for ${business.name} Saalbach.
blocks:
- anchor: imprint
title:
$de: Impressum
$en: Imprint
sections:
- title: ${business.name}
paragraphs:
- $de: 'Informationen und Offenlegung gemäß §5 (1) ECG, § 25 MedienG, § 63 GewO und § 14 UGB:'
$en: 'Information and disclosure according to §5 (1) ECG, § 25 Media Act, § 63 Trade Regulation Act, and § 14 Commercial Code (UGB):'
- $de: |-
Betreiber: ${business.operator}
Anschrift: ${business.address}
Telefon: ${business.phone}
Email: ${business.email}
$en: |-
Operator: ${business.operator}
Address: ${business.address}
Phone: ${business.phone}
Email: ${business.email}
- $de: |-
UID-Nr: ${business.vat}
Gewerbeaufsichtbehörde: ${business.authority}
Mitgliedschaft: ${business.membership}
Rechtsvorschrift: ${business.regulation}
$en: |-
VAT ID: ${business.vat}
Supervisory authority: ${business.authority}
Chamber membership: ${business.membership}
Legal regulations: ${business.regulation}
- title:
$de: Verweise und Links
$en: External Links
paragraphs:
- $de: Trotz sorgfältiger inhaltlicher Kontrolle übernimmt der Betreiber dieser Website keine Verantwortung für Inhalte externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich. Sollten Sie dennoch auf einen ausgehenden Link stoßen, der auf eine Website mit rechtswidrigen Inhalten oder Tätigkeiten verweist, bitten wir um einen entsprechenden Hinweis. In einem solchen Fall wird der Link gemäß § 17 Abs. 2 ECG umgehend entfernt.
$en: Despite careful content control, the operator of this website assumes no responsibility for the content of external links. The operators of the linked websites are solely responsible for their content. If you come across a link that leads to unlawful content or activity, we kindly ask for your notification. Such links will be removed promptly in accordance with § 17 (2) ECG.
- $de: Der Betreiber dieser Website achtet die Urheberrechte Dritter mit größtmöglicher Sorgfalt. Falls Sie dennoch eine Urheberrechtsverletzung bemerken, ersuchen wir ebenfalls um Mitteilung. Bei Bekanntwerden einer entsprechenden Rechtsverletzung werden die betroffenen Inhalte unverzüglich entfernt.
$en: The operator of this website takes great care to respect the copyrights of third parties. If you notice any copyright infringement, please notify us. Upon becoming aware of such a violation, the affected content will be removed without delay.
- title:
$de: Haftungsausschluss
$en: Disclaimer
paragraphs:
- $de: Der Betreiber übernimmt keine Gewähr für die Aktualität, Richtigkeit, Vollständigkeit oder Qualität der bereitgestellten Informationen. Haftungsansprüche jeglicher Art, die durch die Nutzung oder Nichtnutzung der angebotenen Inhalte bzw. durch die Nutzung fehlerhafter oder unvollständiger Informationen verursacht werden, sind ausgeschlossen sofern kein nachweislich vorsätzliches oder grob fahrlässiges Verschulden vorliegt. Alle Angebote sind freibleibend und unverbindlich. Der Betreiber behält sich ausdrücklich vor, Teile der Seiten oder das gesamte Angebot ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.
$en: The operator assumes no liability for the accuracy, completeness, or quality of the information provided. Claims for damages of any kind arising from the use or non-use of the information presented, or from the use of incorrect or incomplete content, are excluded unless caused by demonstrably intentional or grossly negligent behavior. All offers are non-binding. The operator expressly reserves the right to modify, supplement, delete, or temporarily or permanently cease publication of parts of the site or the entire offer without prior notice.
- $de: Aufgrund der technischen Gegebenheiten des Internets kann keine Gewähr für die ständige Verfügbarkeit, Authentizität oder Fehlerfreiheit der Website übernommen werden. Jegliche Haftung für unmittelbare oder mittelbare Schäden, die durch die Nutzung oder vorübergehende Nichtverfügbarkeit der Website entstehen, ist soweit gesetzlich zulässig ausgeschlossen.
$en: Due to the technical nature of the internet, no guarantee can be given for uninterrupted availability, authenticity, or accuracy of the website. Liability for any direct or indirect damages resulting from the use or temporary unavailability of this website is excluded to the extent permitted by law.
- title:
$de: Urheberrecht und Nutzung
$en: Copyright and Use
paragraphs:
- $de: Die Inhalte dieser Website (Texte, Bilder, Grafiken, Videos etc.) sind urheberrechtlich geschützt und ausschließlich für den persönlichen Gebrauch bestimmt. Eine darüber hinausgehende Nutzung insbesondere die Vervielfältigung, Speicherung in Datenbanken, gewerbliche Nutzung oder Weitergabe an Dritte ist ohne ausdrückliche Zustimmung des Betreibers nicht gestattet. Die Einbindung einzelner Seiten in fremde Frames ist unzulässig.
$en: The content of this website (texts, images, graphics, videos, etc.) is protected by copyright and intended solely for personal use. Any further use — including reproduction, storage in databases, commercial use, or distribution to third parties — is not permitted without the explicit consent of the operator. Embedding individual pages of this website in external frames is prohibited.
- $de: Der Betreiber ist bemüht, in allen Publikationen Urheberrechte Dritter zu beachten oder auf lizenzfreie bzw. eigene Inhalte zurückzugreifen. Sollte dennoch eine Urheberrechtsverletzung vorliegen, bitten wir um einen Hinweis. Bei Bekanntwerden werden entsprechende Inhalte umgehend entfernt.
$en: The operator makes every effort to respect the copyrights of third parties or to use either self-created or license-free content. If you suspect a copyright violation, please inform us. In such cases, the relevant content will be removed promptly.
- anchor: privacy
title:
$de: Datenschutz
$en: Privacy Policy
sections:
- title:
$de: Einleitung
$en: Introduction
paragraphs:
- $de: Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Wir behandeln Ihre Daten vertraulich und entsprechend der geltenden Datenschutzvorschriften (DSGVO, TKG 2003). Diese Erklärung informiert Sie darüber, welche Daten wir im Rahmen unseres Webauftritts erfassen, speichern und wie wir mit ihnen umgehen.
$en: Protecting your personal data is very important to us. We treat your data confidentially and in accordance with applicable data protection laws (GDPR, TKG 2003). This policy explains what data is collected when you use our website, how it is processed, and how it is protected.
- title:
$de: Umgang mit personenbezogenen Daten
$en: Handling of Personal Data
paragraphs:
- $de: Wenn Sie über unsere Website oder per E-Mail Kontakt mit uns aufnehmen, verarbeiten wir die von Ihnen bereitgestellten personenbezogenen Daten ausschließlich zur Bearbeitung Ihrer Anfrage oder für damit zusammenhängende Zwecke.
$en: If you contact us via our website or by email, we will use the personal data you provide exclusively to process your inquiry or for related purposes.
- $de: Die Daten werden nur so lange gespeichert, wie es für die Erfüllung des jeweiligen Zwecks notwendig ist oder rechtliche Aufbewahrungspflichten dies vorsehen. Eine Weitergabe an Dritte erfolgt nicht, es sei denn, dies ist gesetzlich erforderlich oder Sie haben ausdrücklich zugestimmt.
$en: Your data will only be stored as long as necessary to fulfill the intended purpose or as required by legal retention obligations. No data will be shared with third parties unless required by law or explicitly consented to by you.
- title:
$de: Serverprotokolle
$en: Server Logs
paragraphs:
- $de: 'Beim Aufruf unserer Website werden automatisch Informationen durch den Webserver protokolliert (sogenannte Server-Logfiles). Dazu gehören unter anderem:'
$en: 'When visiting our website, technical information is automatically recorded by the web server (so-called server log files). This includes, for example:'
- $de: |-
• IP-Adresse
• Datum und Uhrzeit des Zugriffs
• besuchte Seiten
• verwendeter Browser und Betriebssystem
• Spracheinstellungen
• ggf. Referrer-URL
$en: |-
• IP address
• Date and time of access
• Visited pages
• Browser and operating system used
• Language settings
• Referrer URL (if applicable)
- $de: Diese Daten sind technisch notwendig, um den sicheren und stabilen Betrieb der Website zu gewährleisten. Sie lassen keinen direkten Rückschluss auf Ihre Person zu und werden nicht mit anderen Datenquellen zusammengeführt.
$en: These logs are essential for the safe and stable operation of the site. The data does not allow for direct identification of users and is not merged with other data sources.
- title: Cookies
paragraphs:
- $de: Unsere Website verwendet ausschließlich technisch notwendige Cookies. Wir setzen keine Tracking-Technologien ein und analysieren keine Nutzerverhalten.
$en: This website only uses technically necessary cookies. No tracking or analytics tools are in use.
- $de: Das einzige gesetzte Cookie dient der Unterscheidung zwischen Sommer- und Winterdarstellung der Website. Es enthält keine personenbezogenen Informationen und wird nur für diesen funktionalen Zweck verwendet.
$en: The only cookie stored is used to toggle between summer and winter versions of the website. This cookie does not contain any personal information and is used solely for this visual adjustment.
- title:
$de: Ihre Rechte
$en: Your Rights
paragraphs:
- $de: Sie haben das Recht auf Auskunft über Ihre bei uns gespeicherten personenbezogenen Daten sowie auf Berichtigung, Löschung oder Einschränkung der Verarbeitung. Sie können der Datenverarbeitung jederzeit widersprechen und sofern zutreffend Ihr Recht auf Datenübertragbarkeit geltend machen.
$en: You have the right to access your stored personal data, request correction or deletion, and restrict or object to processing. If applicable, you may also request data portability.
- $de: Wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten gegen geltendes Datenschutzrecht verstößt, haben Sie das Recht, sich bei der zuständigen Datenschutzbehörde zu beschweren oder uns direkt zu kontaktieren.
$en: If you believe that your data is being processed in violation of data protection laws, you have the right to file a complaint with the relevant data protection authority or contact us directly.
- anchor: accessibility
title:
$de: Barrierefreiheit
$en: Accessibility
sections:
- title:
$de: Allgemeines
$en: General Information
paragraphs:
- $de: Der Betreiber dieser Website ist bemüht, die Inhalte möglichst barrierefrei bereitzustellen. Auch wenn das österreichische Barrierefreiheitsgesetz (BaFG) für Kleinstunternehmen nicht verpflichtend ist, orientiert sich der Betreiber freiwillig an den anerkannten Richtlinien für barrierefreie Webinhalte (WCAG).
$en: The operator of this website strives to make the content as accessible as possible. While the Austrian Accessibility Act (BaFG) does not apply to microenterprises, the operator voluntarily follows the recognized guidelines for accessible web content (WCAG).
- title:
$de: Rechtlicher Rahmen
$en: Legal Framework
paragraphs:
- $de: Laut österreichischem Barrierefreiheitsgesetz (BaFG) sind Kleinstunternehmen also Betriebe mit weniger als zehn Mitarbeitenden und einem Jahresumsatz oder einer Bilanzsumme unter 2 Millionen Euro von den gesetzlichen Verpflichtungen zur digitalen Barrierefreiheit ausgenommen, sofern ausschließlich Dienstleistungen online angeboten werden.
$en: According to the Austrian Accessibility Act (BaFG), microenterprises — defined as businesses with fewer than ten employees and annual revenue or balance sheet totals under 2 million euros — are exempt from the legal obligations of digital accessibility, provided they only offer online services.
- $de: Der Betreiber fällt unter diese gesetzliche Ausnahme und ist daher nicht verpflichtet, eine formale Barrierefreiheitserklärung zu veröffentlichen.
$en: The operator qualifies as such a microenterprise and is therefore not legally required to publish a formal accessibility statement.
- $de: Trotzdem wurde bei der Gestaltung der Website auf eine möglichst barrierearme Umsetzung geachtet. Dazu zählen unter anderem gut lesbare Schriftarten, ausreichende Farbkontraste, eine klare Inhaltsstruktur und die Bedienbarkeit ohne Maus. Ziel ist es, die Nutzung der Website für möglichst viele Menschen zugänglich zu machen.
$en: Nevertheless, efforts have been made to design this website in a way that minimizes barriers. This includes readable fonts, sufficient color contrast, a clear content structure, and keyboard navigability. The goal is to make the website usable for as many people as possible.
- title:
$de: Kontakt bei Barrieren
$en: Reporting Accessibility Issues
paragraphs:
- $de: Sollten Inhalte oder Funktionen dieser Website nicht barrierefrei zugänglich sein, wird um Rückmeldung gebeten. Hinweise können jederzeit per E-Mail an ${business.email} übermittelt werden.
$en: If you encounter any inaccessible content or functionality on this website, you are encouraged to report it. Please send your feedback via email to ${business.email}.

View File

@ -1,7 +1 @@
export default defineAppConfig({
content: {
matrix: undefined,
business: undefined,
legal: undefined,
}
})
export default defineAppConfig({})

View File

@ -1,12 +1,18 @@
<script setup lang="ts">
const { locale, localeCodes, matrixPath } = useContent()
const { preferLocale } = useContentPreference()
const config = useContentConfig()
const {preferLocale} = useContentPreference()
const {locale, path} = useContentInjected()
const candidates = computed(() => localeCodes.filter(l => l !== locale.value))
const locales = config.locale?.list ?? []
const candidates = computed(() => locales.filter(l => l.code !== locale.value))
</script>
<template>
<NuxtLink v-for="candidate in candidates" :key="candidate" :to="{ path: matrixPath({locale: candidate}), query: { freeze: 'true' } }" @click="preferLocale(candidate)">
{{ candidate.toUpperCase() }}
<NuxtLink
v-for="candidate in candidates"
:key="candidate.code"
:to="{ path: path(undefined, {locale: candidate.code}).value, query: { freeze: 'true' }}"
@click="preferLocale(candidate.code)">
{{ candidate.code.toUpperCase() }}
</NuxtLink>
</template>

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
const { variant, variants, matrixPath } = useContent()
const { preferVariant } = useContentPreference()
const config = useContentConfig()
const {preferVariant} = useContentPreference()
const {variant, path} = useContentInjected()
const variants = config.variant?.list ?? []
const candidates = computed(() => variants.filter(v => v.code !== variant.value))
</script>
@ -9,7 +11,7 @@ const candidates = computed(() => variants.filter(v => v.code !== variant.value)
<NuxtLink
v-for="candidate in candidates"
:key="candidate.code"
:to="{ path: matrixPath({variant: candidate.code}), query: { freeze: 'true' }}"
:to="{ path: path(undefined, {variant: candidate.code}).value, query: { freeze: 'true' }}"
class="flex items-center justify-center"
@click="preferVariant(candidate.code)">
<Icon :name="candidate.icon"/>

View File

@ -1,178 +0,0 @@
import { isTemplate, expandTemplate } from '../utils/content-template'
import { computed } from 'vue'
import { useRoute } from '#app'
const specializations: Record<string, object> = {}
function buildSpecialization(locale: string, variant: string): object {
const config = useAppConfig()
const locales = config.content.matrix.locale.list.map(l => l.code)
const variants = config.content.matrix.variant.list.map(v => v.code)
const allTags = new Set([...locales, ...variants])
function split(key: string): {base?: string; tags: string[]} {
const parts = key.split('@')
if (allTags.has(parts[0])) {
return {base: undefined, tags: parts}
}
return {base: parts[0], tags: parts.slice(1)}
}
function relevant(tags: string[]): boolean {
if (tags.length < 1) {
return true
}
for (const tag of tags) {
if (tag !== locale && tag !== variant) {
return false
}
}
return true
}
function filterObject(object: any): any {
if (Array.isArray(object)) {
return object.map(e => filterObject(e)).filter(e => e !== undefined)
}
if (typeof object !== 'object') {
return object
}
const copy: Record<string, any> = {}
let anonymous: [number, any][] = []
for (const key in object) {
const {base, tags} = split(key)
if (!relevant(tags)) {
continue
}
const value = filterObject(object[key])
if (base === undefined) {
anonymous.push([tags.length, value])
} else {
copy[base] = value
}
}
if (Object.keys(copy).length > 0 && anonymous.length > 0) {
console.warn(`Invalid mixing of keys in object ${object}`)
}
if (Object.keys(copy).length > 0) {
return copy
}
if (anonymous.length < 1) {
console.warn("Unable to parse object", object)
}
anonymous.sort((a, b) => b[0] - a[0])
if (anonymous[0][0] > 2) {
console.warn(`More than two tags per key in object ${object}`)
}
return anonymous[0][1]
}
const filtered = filterObject(config.content)
function deepReplace(obj: any, predicate: (node: any) => boolean, replacer: (node: any) => any): void {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (predicate(obj[i])) {
obj[i] = replacer(obj[i]);
} else if (typeof obj[i] === 'object' && obj[i] !== null) {
deepReplace(obj[i], predicate, replacer);
}
}
} else if (typeof obj === 'object' && obj !== null) {
for (const key of Object.keys(obj)) {
if (predicate(obj[key])) {
obj[key] = replacer(obj[key]);
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
deepReplace(obj[key], predicate, replacer);
}
}
}
}
deepReplace(filtered, (node: any) => isTemplate(node), (node: any) => expandTemplate(node, filtered))
return filtered
}
function getSpecialization(locale: string, variant: string): object {
const key = locale + '/' + variant
if (!(key in specializations)) {
specializations[key] = buildSpecialization(locale, variant)
}
return specializations[key]
}
export function useContent() {
const config = useAppConfig()
const route = useRoute()
const {getLocaleVariant, buildPrefix} = useContentPrefix()
const locales = config.content.matrix.locale.list.map(l => l.code)
const variants = config.content.matrix.variant.list.map(v => v.code)
const localeVariant = computed(() => getLocaleVariant(route.path)!)
const currentLocale = computed(() => localeVariant.value[0])
const currentVariant = computed(() => localeVariant.value[1])
function matrixPath(options: { page?: string; anchor?: string; locale?: string; variant?: string }): string {
let page = options.page
if (page && !page.startsWith('/')) {
page = '/' + page
}
if (page === undefined) {
page = route.fullPath
const [locale, variant] = getLocaleVariant(page)!
const length = buildPrefix(locale, variant).length
page = page.slice(length)
}
const prefix = buildPrefix(options.locale ?? currentLocale.value, options.variant ?? currentVariant.value)
let base = prefix + page
if (base.endsWith('/')) {
base = base.slice(0, -1)
}
return options.anchor ? `${base}#${options.anchor}` : base
}
function p(page: string, anchor?: string): string {
return matrixPath({page: page, anchor: anchor})
}
const specialization = computed<any>(() => getSpecialization(currentLocale.value, currentVariant.value))
return {
locale: currentLocale,
locales: config.content.matrix.locale.list,
localeCodes: locales,
variant: currentVariant,
variants: config.content.matrix.variant.list,
variantCodes: variants,
matrixPath: matrixPath,
p: p,
c: specialization,
}
}

View File

@ -0,0 +1,6 @@
import {type Configuration} from '../utils/content-config'
import config from 'virtual:content/content.config'
export function useContentConfig() {
return config as Configuration
}

View File

@ -0,0 +1,98 @@
import {type JsonValue} from '../utils/content-canonical'
import {buildContent, expandContent} from '../utils/content-reduced'
import {useContentPreference} from "./useContentPreference"
import config from 'virtual:content/content.config'
import shortcuts from 'virtual:content/shortcuts'
export function useContentInjected(slug?: string, global?: JsonValue, local?: JsonValue) {
if (!slug) {
throw new Error('useContentInjected called without arguments, there might be a problem with the Vite plugin')
}
const route = useRoute()
const {preferredLocale, preferredVariant} = useContentPreference()
const lo = computed(() => (route.params.locale as string | undefined) ?? preferredLocale.value)
const va = computed(() => (route.params.variant as string | undefined) ?? preferredVariant.value)
const g = computed(() => getContent('content.global', lo.value, va.value, global, local))
const l = computed(() => getContent(slug, lo.value, va.value, global, local))
function path(page?: string, options?: {locale?: string, variant?: string}) {
return computed(() => {
let result: string // don't touch page, this will mess with reactivity
if (page === undefined) {
result = route.fullPath
function replace(what: string, value: string) { // replaces only the first occurrence
const expression = new RegExp(`/${what}(?=[/?#]|$)`)
return result.replace(expression, `/${value}`)
}
result = lo.value ? replace(lo.value, '[locale]') : result
result = va.value ? replace(va.value, '[variant]') : result
} else {
const index = page.search(/[?#]/)
const path = index === -1 ? page : page.slice(0, index)
if (path in shortcuts) {
result = shortcuts[path] + page.slice(index === -1 ? page.length : index)
} else {
result = page
}
}
if (!result.startsWith('/')) {
result = '/' + result
}
result = lo.value ? result.replace('[locale]', options?.locale ?? lo.value ?? '') : result
result = va.value ? result.replace('[variant]', options?.variant ?? va.value ?? '') : result
return result
})
}
return {
locale: lo,
variant: va,
global: g,
local: l,
g: g,
l: l,
path: path,
p: path
}
}
const cache = new Map<string, JsonValue | undefined>()
function getContent(slug: string, locale?: string, variant?: string, global?: JsonValue, local?: JsonValue): JsonValue | undefined {
const key = `${slug}${locale}${variant}`
const hit = cache.get(key)
if (hit !== undefined) {
return hit
}
const locales: string[] = config.locale?.list.map(l => l.code) ?? []
const variants: string[] = config.variant?.list.map(v => v.code) ?? []
const content = slug === 'content.global' ? global : local
let [reduced, expand] =
content === undefined ? [undefined, false] : buildContent(locale, locales, variant, variants, content)
if (reduced && expand) {
if (slug === 'content.global') {
reduced = expandContent(reduced, [reduced])
} else {
const g = getContent('content.global', locale, variant, global, local)
reduced = expandContent(reduced, g ? [reduced, g] : [reduced])
}
}
cache.set(key, reduced)
return reduced
}

View File

@ -1,19 +1,18 @@
import type {CookieRef} from "nuxt/app"
import type {CookieRef} from 'nuxt/app'
import config from 'virtual:content/content.config'
export function useContentPreference() {
const config = useAppConfig()
const locales = config.content.matrix.locale.list.map(l => l.code)
const variants = config.content.matrix.variant.list.map(v => v.code)
const locales = config.locale?.list.map(l => l.code) ?? []
const variants = config.variant?.list.map(v => v.code) ?? []
const localeCookie = useCookie<string>('locale', {
sameSite: 'lax',
maxAge: config.content.matrix.locale.cookieMaxAge
maxAge: config.locale?.cookieMaxAge ?? 0
})
const variantCookie = useCookie<string>('variant', {
sameSite: 'lax',
maxAge: config.content.matrix.variant.cookieMaxAge
maxAge: config.variant?.cookieMaxAge ?? 0
})
function prefer(list: string[], cookie: CookieRef<string>) {
@ -24,9 +23,9 @@ export function useContentPreference() {
}
}
function preferred(list: string[], cookie: CookieRef<string | undefined>, fallback: string) {
function preferred(list: string[], cookie: CookieRef<string | undefined>, fallback: string | undefined) {
return computed(() => {
if (cookie.value!==undefined && list.includes(cookie.value)) {
if (cookie.value !== undefined && list.includes(cookie.value)) {
return cookie.value
} else if (cookie.value !== undefined) {
cookie.value = undefined
@ -37,9 +36,9 @@ export function useContentPreference() {
}
return {
preferredLocale: preferred(locales, localeCookie, config.content.matrix.locale.default),
preferredLocale: preferred(locales, localeCookie, config.locale?.default),
preferLocale: prefer(locales, localeCookie),
preferredVariant: preferred(variants, variantCookie, config.content.matrix.variant.default),
preferredVariant: preferred(variants, variantCookie, config.variant?.default),
preferVariant: prefer(variants, variantCookie)
}
}

View File

@ -1,53 +0,0 @@
export function useContentPrefix() {
const config = useAppConfig()
const locales: string[] = config.content.matrix.locale.list.map(l => l.code)
const variants: string[] = config.content.matrix.variant.list.map(v => v.code)
type Prefix = [string, string, string]
let prefixes: Prefix[] = []
if (locales.length>1 && variants.length>1) {
prefixes = locales.flatMap(l => variants.map(v => [`/${l}/${v}`, l, v] as Prefix))
} else if (locales.length>1 && variants.length===1) {
prefixes = locales.map(l => [`/${l}`, l, variants[0]!])
} else if (locales.length===1 && variants.length>1) {
prefixes = variants.map(v => [`/${v}`, locales[0]!, v])
} else if (locales.length===1 && variants.length===1) {
prefixes = [['', locales[0]!, variants[0]!]]
} else {
console.warn('Missing locales and variants in AppConfig')
}
const terminators = new Set(['/', '#', '?'])
function getLocaleVariant(path: string): [string, string] | undefined {
for (const [p, l, v] of prefixes) {
if (path.startsWith(p) && (path.length===p.length || terminators.has(path[p.length]))) {
return [l, v]
}
}
return undefined
}
function buildPrefix(locale: string, variant: string): string {
let segments: string[] = []
if (locales.length > 1) {
segments.push(locale)
}
if (variants.length > 1) {
segments.push(variant)
}
return (segments.length ? '/' : '') + segments.join('/')
}
return {
prefixes: prefixes.map(p => p[0]),
getLocaleVariant: getLocaleVariant,
buildPrefix: buildPrefix
}
}

View File

@ -0,0 +1,18 @@
export default {
locale: {
default: 'de',
list: [
{ code: 'de', name: { de: 'Deutsch', en: 'German' }, icon: 'german' },
{ code: 'en', name: { de: 'Englisch', en: 'English' }, icon: 'english' },
],
cookieMaxAge: 60 * 60 * 24 * 365 * 3
},
variant: {
default: 'winter',
list: [
{ code: 'summer', name: { de: 'Sommer', en: 'Summer' }, icon: 'i-lucide-sun' },
{ code: 'winter', name: { de: 'Winter', en: 'Winter' }, icon: 'i-lucide-snowflake' },
],
cookieMaxAge: 60 * 60 * 24
}
}

View File

@ -0,0 +1,24 @@
export default defineNuxtRouteMiddleware((to, from) => {
// important: routes do not exist in middleware, never (in)directly use e.g. useRoute
if (!import.meta.client) return
if (to.matched.length) {
const config = useContentConfig()
const {preferredLocale, preferredVariant} = useContentPreference()
const locales = config.locale?.list.map(l => l.code) ?? []
if (to.params.locale !== undefined && !locales.includes(to.params.locale)) {
return navigateTo('/' + preferredLocale.value + to.fullPath.slice(to.params.locale.length + 1))
}
const variants = config.variant?.list.map(v => v.code) ?? []
if (to.params.variant !== undefined && !variants.includes(to.params.variant)) {
const prefix = to.params.locale === undefined ? '/' : '/' + to.params.locale + '/'
return navigateTo(prefix + preferredVariant.value + to.fullPath.slice(prefix.length + to.params.variant.length))
}
} else {
const {preferredLocale, preferredVariant} = useContentPreference()
return navigateTo('/' + [preferredLocale.value, preferredVariant.value].filter(p => !!p).join('/'))
}
})

View File

@ -1,16 +0,0 @@
export default defineNuxtRouteMiddleware((to, from) => {
// important: routes do not exist in middleware, never (in)directly use e.g. useRoute
const {getLocaleVariant, buildPrefix} = useContentPrefix()
const localeVariant = getLocaleVariant(to.path)
if (to.matched.length && localeVariant) return
if (!to.matched.length && localeVariant) {
const [locale, variant] = localeVariant
return navigateTo(buildPrefix(locale, variant))
}
const {preferredLocale, preferredVariant} = useContentPreference()
return navigateTo(buildPrefix(preferredLocale.value, preferredVariant.value))
})

View File

@ -0,0 +1,94 @@
import {addVitePlugin, defineNuxtModule, getLayerDirectories} from '@nuxt/kit'
import {getLayerFiles, getLayerContent} from '../utils/content-files'
import {getRoutes, getExpandedRoutes, getRouteShortcuts, getConfig} from '../utils/content-routes'
import {join, relative, dirname, basename, extname} from 'path'
import {existsSync} from 'fs'
/* Rationale:
1) Every project file containing useInjectedContent() is transformed and virtual modules are imported
2) Upon request the virtual modules are compiled from ts/yaml files
3) If any of the files that back a virtual module change the module is invalidated
*/
export default defineNuxtModule({
setup() {
addVitePlugin(() => ({
name: 'content-import-injector',
resolveId(id) {
if (id.startsWith('virtual:content/')) {
return '\0' + id // convention, hide from further processing
}
},
async load(id) {
if (id.startsWith('\0' + 'virtual:content/')) {
const slug = id.slice(17)
if (slug === 'shortcuts') {
const routes = await getRoutes()
const shortcuts = getRouteShortcuts(routes)
return `export default ${JSON.stringify(shortcuts)}`
} else if (slug === 'content.config') {
const file = getLayerDirectories().map(d => join(d.root, 'content.config.ts')).find(f => existsSync(f))
if (file) {
this.addWatchFile(file)
return `export {default} from '${file}'`
}
} else {
const files = getLayerFiles(slug)
const content = await getLayerContent(files)
files.filter(f => existsSync(f)).forEach(f => this.addWatchFile(f))
return `export const content = ${JSON.stringify(content)}`
}
}
},
hotUpdate({file, server}) {
const root = getLayerDirectories().map(d => d.root).find(r => file.startsWith(r))
if (root !== undefined) {
const path = relative(root, file)
const slug = join(dirname(path), basename(path, extname(path)))
const module = server.moduleGraph.getModuleById('\0' + `virtual:content/${slug}`)
if (module) {
server.moduleGraph.invalidateModule(module)
this.environment.hot.send({ type: 'full-reload' })
}
}
return []
},
transform(code, id) {
let slug: string | undefined = undefined
for (const root of getLayerDirectories().map(d => d.root)) {
if (id.startsWith(root) && !id.includes('/node_modules/')) {
const path = relative(root, id).split('?')[0]!
slug = join(dirname(path), basename(path, extname(path)))
break
}
}
if (slug === undefined) {
return null
}
const expression = /\buseContentInjected\s*\(\s*\)/g
if (!expression.test(code)) return null
const transformed = code.replace(expression,
`useContentInjected('${slug}', a23631da455de90c5a945da16897d0bd, d61397115e513f2bb1d8e872b7e7788f)`)
return `
import {content as a23631da455de90c5a945da16897d0bd} from 'virtual:content/content.global';
import {content as d61397115e513f2bb1d8e872b7e7788f} from 'virtual:content/${slug}';
${transformed}
`
}
}))
}
})

View File

@ -1,8 +1,22 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import {getConfig, getExpandedRoutes} from './utils/content-routes'
export default defineNuxtConfig({
$meta: {
name: 'content', // creates alias #layers/content
},
devtools: { enabled: true },
devtools: {enabled: true},
hooks: {
async 'prerender:routes'(context) {
context.routes.clear()
const config = await getConfig()
const routes = await getExpandedRoutes(config)
for (const route of routes) {
context.routes.add(`/${route}`)
}
},
}
})

View File

@ -11,7 +11,12 @@
"lint": "eslint ."
},
"dependencies": {
"vue-router": "^4.5.1"
"esbuild": "^0.25.11",
"vue-router": "^4.5.1",
"yaml": "^2.8.1"
},
"peerDependencies": {
"@nuxt/kit": "^4.1.3"
},
"devDependencies": {
"@nuxt/eslint": "latest",

View File

@ -0,0 +1,381 @@
export type Primitive = string | number | boolean | null
export type JsonObject = { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
export type JsonValue = Primitive | JsonObject | JsonArray
export type CanonicalValue = CanonicalPrimitive | CanonicalObject | CanonicalArray
// We do not use Map or Set as properties, they cant be serialized to JSON properly
// Instead we use objects and arrays under the hood, and optimize speed/memory for runtime, not build time
// TODO: In fromCanonicalJson the .x are not set (not critical actually)
export class CanonicalBase {
protected readonly x: number = 0
private readonly t?: string[]
constructor(tags: string[]) {
const clean = cleanTags(tags)
this.t = clean.length ? clean : undefined
}
get tags(): readonly string[] {
return this.t ?? []
}
get delete(): boolean {
return this.t?.includes('delete') ?? false
}
get replace(): boolean {
return this.t?.includes('replace') ?? false
}
static fromCanonicalJson(json: JsonObject): CanonicalBase {
const base = Object.create(CanonicalBase.prototype)
base.t = json.t
return base as CanonicalBase
}
mergeInto(other: CanonicalBase): CanonicalBase {
throw new Error('Not implemented')
}
dropTags(except: string[]): CanonicalBase {
throw new Error('Not implemented')
}
reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any {
throw new Error('Not implemented')
}
}
export class CanonicalPrimitive extends CanonicalBase {
protected override readonly x: number = 1
private v: Primitive | string[]
private e?: boolean
get value(): Readonly<Primitive | string[]> {
return this.v
}
get expand(): boolean {
return this.e ?? false
}
constructor(tags: string[], value: Primitive | string[], expand?: boolean) {
super(tags)
this.v = value
this.e = expand
}
static fromInputJson(outerTags: string[], primitive: Primitive): CanonicalPrimitive {
let v: Primitive | string[]
let e: boolean | undefined = undefined
if (typeof primitive === 'string') {
const segments = primitive.split(/\$\{(.*?)\}/)
if (segments.length > 1) {
v = segments
e = true
} else {
v = primitive
}
} else {
v = primitive
}
return new CanonicalPrimitive(outerTags, v, e)
}
static override fromCanonicalJson(json: JsonObject): CanonicalPrimitive {
const primitive = super.fromCanonicalJson(json) as CanonicalPrimitive
Object.setPrototypeOf(primitive, CanonicalPrimitive.prototype)
primitive.v = json.v as Primitive | string[]
primitive.e = json.e as boolean | undefined
return primitive
}
override mergeInto(other: CanonicalPrimitive): CanonicalPrimitive {
return this
}
override dropTags(except: string[]): CanonicalPrimitive {
return new CanonicalPrimitive(this.tags.filter(t => except.includes(t)), this.v, this.e)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): CanonicalPrimitive | Primitive | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
if (this.expand) {
options.expand = true
return this
}
return this.v as Primitive
}
resolve(resolver: (key: string) => JsonValue): string {
return (this.v as string[]).map((p, i) => i % 2 ? resolver(p) : p).join('')
}
}
export class CanonicalObject extends CanonicalBase {
protected override readonly x: number = 2
private n?: Record<string, CanonicalValue>
private a?: CanonicalValue[]
constructor(
tags: string[],
named: Record<string, CanonicalValue>,
anonymous: CanonicalValue[],
) {
super(tags)
if (Object.keys(named).length) {
this.n = named
}
if (anonymous.length) {
this.a = anonymous
}
}
get named(): Readonly<Record<string, CanonicalValue>> {
return this.n ?? {}
}
get anonymous(): readonly CanonicalValue[] {
return this.a ?? []
}
static fromInputJson(outerTags: string[], json: JsonObject): CanonicalObject {
let n: Record<string, CanonicalValue> = {}
let a: CanonicalValue[] = []
for (const key in json) {
if (key === '$tags') continue
let [name, ...tags] = key.split('$')
tags = cleanTags(tags)
if (name) {
n[name] = fromInputJson(tags, json[key] as JsonValue)
} else if (tags.length) {
a.push(fromInputJson(tags, json[key] as JsonValue))
} else {
console.error(`No name and no tags found in key '${key}'`)
}
}
return new CanonicalObject([...outerTags, ...getInnerTags(json)], n, a)
}
static override fromCanonicalJson(json: JsonObject): CanonicalObject {
const object = super.fromCanonicalJson(json) as CanonicalObject
Object.setPrototypeOf(object, CanonicalObject.prototype)
if (json.n) {
object.n = {}
for (const [k, v] of Object.entries(json.n)) {
object.n[k] = fromCanonicalJson(v)
}
}
if (json.a) {
object.a = (json.a as JsonValue[]).map(i => fromCanonicalJson(i))
}
return object
}
override mergeInto(other: CanonicalObject): CanonicalObject {
const n: Record<string, CanonicalValue> = {}
for (const key of new Set([...Object.keys(this.named), ...Object.keys(other.named)])) {
let merged: CanonicalValue | undefined = undefined
if (key in this.named && key in other.named) {
merged = mergeInto(this.named[key]!, other.named[key]!)
} else {
merged = key in this.named ? this.named[key] : other.named[key]
}
if (merged) {
n[key] = merged
}
}
return new CanonicalObject([...this.tags], n, [...this.anonymous])
}
override dropTags(except: string[]): CanonicalObject {
const n = Object.fromEntries(Object.entries(this.named).map(([k, v]) => [k, v.dropTags(except)]))
const a = this.anonymous.map(a => a.dropTags(except))
return new CanonicalObject(this.tags.filter(t => except.includes(t)), n, a)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
const n = Object.fromEntries(Object.entries(this.named).map(([k, v]) => [k, v.reduce(options)]).filter(([k, v]) => v !== undefined))
if (Object.keys(n).length) {
return n
}
const a = this.anonymous.map(a => a.reduce(options)).filter(a => a !== undefined)
if (a.length) {
if (a.length > 1) {
console.error(`Multiple anonymous object properties: ${JSON.stringify(a)}`)
}
return a[0]
}
return undefined
}
}
export class CanonicalArray extends CanonicalBase {
protected override readonly x: number = 3
private i?: CanonicalValue[]
constructor(tags: string[], items: CanonicalValue[]) {
super(tags)
if (items.length) {
this.i = items
}
}
get items(): readonly CanonicalValue[] {
return this.i ?? []
}
static fromInputJson(outerTags: string[], json: JsonArray): CanonicalArray {
const tags = [...outerTags]
json = json.filter(item => {
if (typeof item === 'object' && item !== null && Object.keys(item).length === 1 && '$tags' in item) {
tags.push(...getInnerTags(item))
return false
}
return true
})
return new CanonicalArray(tags, json.map(i => fromInputJson([], i)))
}
static override fromCanonicalJson(json: JsonObject): CanonicalArray {
const array = super.fromCanonicalJson(json) as CanonicalArray
Object.setPrototypeOf(array, CanonicalArray.prototype)
if (json.i) {
array.i = (json.i as JsonValue[]).map(i => fromCanonicalJson(i))
}
return array
}
override mergeInto(other: CanonicalArray): CanonicalArray {
console.error("Merging arrays not yet implemented")
return this // TODO
}
override dropTags(except: string[]): CanonicalArray {
const items = (this.i ?? []).map(i => i.dropTags(except))
return new CanonicalArray(this.tags.filter(t => except.includes(t)), items)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
return this.items.map(i => i.reduce(options)).filter(i => i !== undefined)
}
}
export function fromInputJson(outerTags: string[], json: JsonValue): CanonicalValue {
if (Array.isArray(json)) {
return CanonicalArray.fromInputJson(outerTags, json)
} else if (typeof json === 'object' && json !== null) {
return CanonicalObject.fromInputJson(outerTags, json)
}
return CanonicalPrimitive.fromInputJson(outerTags, json)
}
export function fromCanonicalJson(json: JsonValue): CanonicalValue {
if (typeof json === 'object' && json !== null) {
json = json as JsonObject
if (json.x === 1) {
return CanonicalPrimitive.fromCanonicalJson(json)
} else if (json.x == 2) {
return CanonicalObject.fromCanonicalJson(json)
}
return CanonicalArray.fromCanonicalJson(json)
}
throw new Error(`Not a valid canonical object: ${json}`)
}
function mergeInto(self: CanonicalValue, other: CanonicalValue): CanonicalValue | undefined {
if (self instanceof CanonicalBase) {
if (self.delete) {
return undefined
}
if (self.replace) {
return self
}
}
if (self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive
|| self instanceof CanonicalObject && other instanceof CanonicalObject
|| self instanceof CanonicalArray && other instanceof CanonicalArray) {
return self.mergeInto(other)
}
return self
}
export function merge(items: CanonicalValue[]): CanonicalValue | undefined {
if (!items.length) {
return undefined
} else if (items.length === 1) {
return items[0]
}
return items.reduceRight((acc, cur) => mergeInto(cur, acc))
}
function getInnerTags(json: JsonObject): string[] {
const tags = json['$tags']
if (typeof tags === 'string') {
return cleanTags(tags.split(' '))
}
return []
}
function cleanTags(tags: string[]): string[] {
return [...new Set(tags)].map(t => t.trim()).filter(t => !!t).sort()
}

View File

@ -0,0 +1,18 @@
export type Translation = string | Record<string, string>
export interface Option {
code: string
icon: string
name: Translation
}
export interface Specification {
default: string
list: Option[]
cookieMaxAge: number
}
export interface Configuration {
locale?: Specification
variant?: Specification
}

View File

@ -0,0 +1,81 @@
import {getLayerDirectories} from '@nuxt/kit'
import {existsSync, lstatSync} from 'fs'
import {readFile} from 'fs/promises'
import {join} from 'path'
import {parse} from 'yaml'
import {fromInputJson, merge, type CanonicalValue} from './content-canonical'
import {type Plugin, build} from 'esbuild'
export function getLayerFiles(slug: string): string[] {
return getLayerDirectories().flatMap(d => ['mts', 'yaml'].map(e => join(d.root, `${slug}.${e}`)))
}
export async function getLayerContent(files: string[]): Promise<CanonicalValue | undefined> {
const ts = await mergeTsFiles(files.filter(f => f.endsWith('.mts') && existsSync(f) && lstatSync(f).isFile()))
const yaml = await mergeYamlFiles(files.filter(f => f.endsWith('.yaml') && existsSync(f) && lstatSync(f).isFile()))
const merged = merge([ts, yaml].filter(c => c !== undefined))
return merged?.dropTags(['de', 'en', 'summer', 'winter']) // TODO
}
async function mergeTsFiles(files: string[]): Promise<CanonicalValue | undefined> {
if (!files.length) return undefined
try {
const loader: Plugin = {
name: 'file-loader',
setup(build) {
build.onLoad({filter: /\.mts$/}, async (args) => {
let code = await readFile(args.path, 'utf-8')
const index = files.indexOf(args.path)
if (index < files.length - 1) {
code = `
export * from '${files[index + 1]}';
import * as parent from '${files[index + 1]}';
${code}
`
}
return {contents: code, loader: 'ts'}
})
}
}
const bundle = await build({
entryPoints: [files[0]!],
bundle: true,
format: 'esm',
platform: 'node',
target: 'node22',
write: false,
sourcemap: 'inline',
plugins: [loader]
})
const url = 'data:text/javascript;base64,' + Buffer.from(bundle.outputFiles[0]!.text).toString('base64')
const module = await import(url)
return fromInputJson([], module.default)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
console.error(`Unable to load merged ts: ${message}`)
return undefined
}
}
async function mergeYamlFiles(files: string[]): Promise<CanonicalValue | undefined> {
const objects = await Promise.all(files.map(async f => {
try {
return fromInputJson([], parse(await readFile(f, 'utf-8')))
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
console.error(`Unable to load content file ${f}: ${message}`)
return undefined
}
}))
return merge(objects.filter(o => !!o))
}

View File

@ -1,295 +0,0 @@
import type { Translations } from './content-types'
import { t } from './content-template'
export interface LegalSection {
title: Translations,
paragraphs: Translations[]
}
export interface LegalBlock {
anchor: string,
title: Translations,
sections: LegalSection[]
}
export type Legal = LegalBlock[]
export class LegalPageBuilder {
static build(): Legal {
return [
this.buildImprint(),
this.buildPrivacy(),
this.buildAccessibility()
]
}
protected static buildImprint(): LegalBlock {
return {
anchor: 'imprint',
title: {
de: 'Impressum',
en: 'Imprint'
},
sections: [
this.buildImprintBusiness(),
this.buildImprintLinks(),
this.buildImprintDisclaimer(),
this.buildImprintCopyright()
]
}
}
protected static buildImprintBusiness(): LegalSection {
return {
title: t`${'business.name'}`,
paragraphs: [
{
de: 'Informationen und Offenlegung gemäß §5 (1) ECG, § 25 MedienG, § 63 GewO und § 14 UGB:',
en: 'Information and disclosure according to §5 (1) ECG, § 25 Media Act, § 63 Trade Regulation Act, and § 14 Commercial Code (UGB):'
},
{
de: t`Betreiber: ${'business.operator'}\nAnschrift: ${'business.address'}\nTelefon: ${'business.phone'}\nEmail: ${'business.email'}`,
en: t`Operator: ${'business.operator'}\nAddress: ${'business.address'}\nPhone: ${'business.phone'}\nEmail: ${'business.email'}`
},
{
de: t`UID-Nr: ${'business.vat'}\nGewerbeaufsichtbehörde: ${'business.authority'}\nMitgliedschaft: ${'business.membership'}\nRechtsvorschrift: ${'business.regulation'}`,
en: t`VAT ID: ${'business.vat'}\nSupervisory authority: ${'business.authority'}\nChamber membership: ${'business.membership'}\nLegal regulations: ${'business.regulation'}`
}
]
}
}
protected static buildImprintLinks(): LegalSection {
return {
title: {
de: 'Verweise und Links',
en: 'External Links'
},
paragraphs: [
{
de: 'Trotz sorgfältiger inhaltlicher Kontrolle übernimmt der Betreiber dieser Website keine Verantwortung für Inhalte externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren Betreiber verantwortlich. Sollten Sie dennoch auf einen ausgehenden Link stoßen, der auf eine Website mit rechtswidrigen Inhalten oder Tätigkeiten verweist, bitten wir um einen entsprechenden Hinweis. In einem solchen Fall wird der Link gemäß § 17 Abs. 2 ECG umgehend entfernt.',
en: 'Despite careful content control, the operator of this website assumes no responsibility for the content of external links. The operators of the linked websites are solely responsible for their content. If you come across a link that leads to unlawful content or activity, we kindly ask for your notification. Such links will be removed promptly in accordance with § 17 (2) ECG.'
},
{
de: 'Der Betreiber dieser Website achtet die Urheberrechte Dritter mit größtmöglicher Sorgfalt. Falls Sie dennoch eine Urheberrechtsverletzung bemerken, ersuchen wir ebenfalls um Mitteilung. Bei Bekanntwerden einer entsprechenden Rechtsverletzung werden die betroffenen Inhalte unverzüglich entfernt.',
en: 'The operator of this website takes great care to respect the copyrights of third parties. If you notice any copyright infringement, please notify us. Upon becoming aware of such a violation, the affected content will be removed without delay.'
}
]
}
}
protected static buildImprintDisclaimer(): LegalSection {
return {
title: {
de: 'Haftungsausschluss',
en: 'Disclaimer'
},
paragraphs: [
{
de: 'Der Betreiber übernimmt keine Gewähr für die Aktualität, Richtigkeit, Vollständigkeit oder Qualität der bereitgestellten Informationen. Haftungsansprüche jeglicher Art, die durch die Nutzung oder Nichtnutzung der angebotenen Inhalte bzw. durch die Nutzung fehlerhafter oder unvollständiger Informationen verursacht werden, sind ausgeschlossen sofern kein nachweislich vorsätzliches oder grob fahrlässiges Verschulden vorliegt. Alle Angebote sind freibleibend und unverbindlich. Der Betreiber behält sich ausdrücklich vor, Teile der Seiten oder das gesamte Angebot ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.',
en: 'The operator assumes no liability for the accuracy, completeness, or quality of the information provided. Claims for damages of any kind arising from the use or non-use of the information presented, or from the use of incorrect or incomplete content, are excluded unless caused by demonstrably intentional or grossly negligent behavior. All offers are non-binding. The operator expressly reserves the right to modify, supplement, delete, or temporarily or permanently cease publication of parts of the site or the entire offer without prior notice.'
},
{
de: 'Aufgrund der technischen Gegebenheiten des Internets kann keine Gewähr für die ständige Verfügbarkeit, Authentizität oder Fehlerfreiheit der Website übernommen werden. Jegliche Haftung für unmittelbare oder mittelbare Schäden, die durch die Nutzung oder vorübergehende Nichtverfügbarkeit der Website entstehen, ist soweit gesetzlich zulässig ausgeschlossen.',
en: 'Due to the technical nature of the internet, no guarantee can be given for uninterrupted availability, authenticity, or accuracy of the website. Liability for any direct or indirect damages resulting from the use or temporary unavailability of this website is excluded to the extent permitted by law.'
}
]
}
}
protected static buildImprintCopyright(): LegalSection {
return {
title: {
de: 'Urheberrecht und Nutzung',
en: 'Copyright and Use'
},
paragraphs: [
{
de: 'Die Inhalte dieser Website (Texte, Bilder, Grafiken, Videos etc.) sind urheberrechtlich geschützt und ausschließlich für den persönlichen Gebrauch bestimmt. Eine darüber hinausgehende Nutzung insbesondere die Vervielfältigung, Speicherung in Datenbanken, gewerbliche Nutzung oder Weitergabe an Dritte ist ohne ausdrückliche Zustimmung des Betreibers nicht gestattet. Die Einbindung einzelner Seiten in fremde Frames ist unzulässig.',
en: 'The content of this website (texts, images, graphics, videos, etc.) is protected by copyright and intended solely for personal use. Any further use — including reproduction, storage in databases, commercial use, or distribution to third parties — is not permitted without the explicit consent of the operator. Embedding individual pages of this website in external frames is prohibited.'
},
{
de: 'Der Betreiber ist bemüht, in allen Publikationen Urheberrechte Dritter zu beachten oder auf lizenzfreie bzw. eigene Inhalte zurückzugreifen. Sollte dennoch eine Urheberrechtsverletzung vorliegen, bitten wir um einen Hinweis. Bei Bekanntwerden werden entsprechende Inhalte umgehend entfernt.',
en: 'The operator makes every effort to respect the copyrights of third parties or to use either self-created or license-free content. If you suspect a copyright violation, please inform us. In such cases, the relevant content will be removed promptly.'
}
]
}
}
protected static buildPrivacy(): LegalBlock {
return {
anchor: 'privacy',
title: {
de: 'Datenschutz',
en: 'Privacy Policy'
},
sections: [
this.buildPrivacyIntroduction(),
this.buildPrivacyData(),
this.buildPrivacyLogs(),
this.buildPrivacyCookies(),
this.buildPrivacyRights()
]
}
}
protected static buildPrivacyIntroduction(): LegalSection {
return {
title: {
de: 'Einleitung',
en: 'Introduction'
},
paragraphs: [
{
de: 'Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Wir behandeln Ihre Daten vertraulich und entsprechend der geltenden Datenschutzvorschriften (DSGVO, TKG 2003). Diese Erklärung informiert Sie darüber, welche Daten wir im Rahmen unseres Webauftritts erfassen, speichern und wie wir mit ihnen umgehen.',
en: 'Protecting your personal data is very important to us. We treat your data confidentially and in accordance with applicable data protection laws (GDPR, TKG 2003). This policy explains what data is collected when you use our website, how it is processed, and how it is protected.'
}
]
}
}
protected static buildPrivacyData(): LegalSection {
return {
title: {
de: 'Umgang mit personenbezogenen Daten',
en: 'Handling of Personal Data'
},
paragraphs: [
{
de: 'Wenn Sie über unsere Website oder per E-Mail Kontakt mit uns aufnehmen, verarbeiten wir die von Ihnen bereitgestellten personenbezogenen Daten ausschließlich zur Bearbeitung Ihrer Anfrage oder für damit zusammenhängende Zwecke.',
en: 'If you contact us via our website or by email, we will use the personal data you provide exclusively to process your inquiry or for related purposes.'
},
{
de: 'Die Daten werden nur so lange gespeichert, wie es für die Erfüllung des jeweiligen Zwecks notwendig ist oder rechtliche Aufbewahrungspflichten dies vorsehen. Eine Weitergabe an Dritte erfolgt nicht, es sei denn, dies ist gesetzlich erforderlich oder Sie haben ausdrücklich zugestimmt.',
en: 'Your data will only be stored as long as necessary to fulfill the intended purpose or as required by legal retention obligations. No data will be shared with third parties unless required by law or explicitly consented to by you.'
}
]
}
}
protected static buildPrivacyLogs(): LegalSection {
return {
title: {
de: 'Serverprotokolle',
en: 'Server Logs'
},
paragraphs: [
{
de: 'Beim Aufruf unserer Website werden automatisch Informationen durch den Webserver protokolliert (sogenannte Server-Logfiles). Dazu gehören unter anderem:',
en: 'When visiting our website, technical information is automatically recorded by the web server (so-called server log files). This includes, for example:'
},
{
de: '• IP-Adresse\n• Datum und Uhrzeit des Zugriffs\n• besuchte Seiten\n• verwendeter Browser und Betriebssystem\n• Spracheinstellungen\n• ggf. Referrer-URL',
en: '• IP address\n• Date and time of access\n• Visited pages\n• Browser and operating system used\n• Language settings\n• Referrer URL (if applicable)'
},
{
de: 'Diese Daten sind technisch notwendig, um den sicheren und stabilen Betrieb der Website zu gewährleisten. Sie lassen keinen direkten Rückschluss auf Ihre Person zu und werden nicht mit anderen Datenquellen zusammengeführt.',
en: 'These logs are essential for the safe and stable operation of the site. The data does not allow for direct identification of users and is not merged with other data sources.'
}
]
}
}
protected static buildPrivacyCookies(): LegalSection {
return {
title: 'Cookies',
paragraphs: [
{
de: 'Unsere Website verwendet ausschließlich technisch notwendige Cookies. Wir setzen keine Tracking-Technologien ein und analysieren keine Nutzerverhalten.',
en: 'This website only uses technically necessary cookies. No tracking or analytics tools are in use.'
},
{
de: 'Das einzige gesetzte Cookie dient der Unterscheidung zwischen Sommer- und Winterdarstellung der Website. Es enthält keine personenbezogenen Informationen und wird nur für diesen funktionalen Zweck verwendet.',
en: 'The only cookie stored is used to toggle between summer and winter versions of the website. This cookie does not contain any personal information and is used solely for this visual adjustment.'
}
]
}
}
protected static buildPrivacyRights(): LegalSection {
return {
title: {
de: 'Ihre Rechte',
en: 'Your Rights'
},
paragraphs: [
{
de: 'Sie haben das Recht auf Auskunft über Ihre bei uns gespeicherten personenbezogenen Daten sowie auf Berichtigung, Löschung oder Einschränkung der Verarbeitung. Sie können der Datenverarbeitung jederzeit widersprechen und sofern zutreffend Ihr Recht auf Datenübertragbarkeit geltend machen.',
en: 'You have the right to access your stored personal data, request correction or deletion, and restrict or object to processing. If applicable, you may also request data portability.'
},
{
de: 'Wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten gegen geltendes Datenschutzrecht verstößt, haben Sie das Recht, sich bei der zuständigen Datenschutzbehörde zu beschweren oder uns direkt zu kontaktieren.',
en: 'If you believe that your data is being processed in violation of data protection laws, you have the right to file a complaint with the relevant data protection authority or contact us directly.'
}
]
}
}
protected static buildAccessibility(): LegalBlock {
return {
anchor: 'accessibility',
title: {
de: 'Barrierefreiheit',
en: 'Accessibility'
},
sections: [
this.buildAccessibilityGeneral(),
this.buildAccessibilityFramework(),
this.buildAccessibilityContact(),
]
}
}
protected static buildAccessibilityGeneral(): LegalSection {
return {
title: {
de: 'Allgemeines',
en: 'General Information'
},
paragraphs: [
{
de: 'Der Betreiber dieser Website ist bemüht, die Inhalte möglichst barrierefrei bereitzustellen. Auch wenn das österreichische Barrierefreiheitsgesetz (BaFG) für Kleinstunternehmen nicht verpflichtend ist, orientiert sich der Betreiber freiwillig an den anerkannten Richtlinien für barrierefreie Webinhalte (WCAG).',
en: 'The operator of this website strives to make the content as accessible as possible. While the Austrian Accessibility Act (BaFG) does not apply to microenterprises, the operator voluntarily follows the recognized guidelines for accessible web content (WCAG).'
}
]
}
}
protected static buildAccessibilityFramework(): LegalSection {
return {
title: {
de: 'Rechtlicher Rahmen',
en: 'Legal Framework'
},
paragraphs: [
{
de: 'Laut österreichischem Barrierefreiheitsgesetz (BaFG) sind Kleinstunternehmen also Betriebe mit weniger als zehn Mitarbeitenden und einem Jahresumsatz oder einer Bilanzsumme unter 2 Millionen Euro von den gesetzlichen Verpflichtungen zur digitalen Barrierefreiheit ausgenommen, sofern ausschließlich Dienstleistungen online angeboten werden.',
en: 'According to the Austrian Accessibility Act (BaFG), microenterprises — defined as businesses with fewer than ten employees and annual revenue or balance sheet totals under 2 million euros — are exempt from the legal obligations of digital accessibility, provided they only offer online services.'
},
{
de: 'Der Betreiber fällt unter diese gesetzliche Ausnahme und ist daher nicht verpflichtet, eine formale Barrierefreiheitserklärung zu veröffentlichen.',
en: 'The operator qualifies as such a microenterprise and is therefore not legally required to publish a formal accessibility statement.'
},
{
de: 'Trotzdem wurde bei der Gestaltung der Website auf eine möglichst barrierearme Umsetzung geachtet. Dazu zählen unter anderem gut lesbare Schriftarten, ausreichende Farbkontraste, eine klare Inhaltsstruktur und die Bedienbarkeit ohne Maus. Ziel ist es, die Nutzung der Website für möglichst viele Menschen zugänglich zu machen.',
en: 'Nevertheless, efforts have been made to design this website in a way that minimizes barriers. This includes readable fonts, sufficient color contrast, a clear content structure, and keyboard navigability. The goal is to make the website usable for as many people as possible.'
}
]
}
}
protected static buildAccessibilityContact(): LegalSection {
return {
title: {
de: 'Kontakt bei Barrieren',
en: 'Reporting Accessibility Issues'
},
paragraphs: [
{
de: t`Sollten Inhalte oder Funktionen dieser Website nicht barrierefrei zugänglich sein, wird um Rückmeldung gebeten. Hinweise können jederzeit per E-Mail an ${'business.email'} übermittelt werden.`,
en: t`If you encounter any inaccessible content or functionality on this website, you are encouraged to report it. Please send your feedback via email to ${'business.email'}.`
}
]
}
}
}

View File

@ -0,0 +1,121 @@
import {type JsonValue, fromCanonicalJson, CanonicalPrimitive} from "./content-canonical";
export function buildContent(
locale: string | undefined,
locales: string[],
variant: string | undefined,
variants: string[],
content: JsonValue
): [JsonValue, boolean] {
function check(all: string[], specified: string[], query: string): boolean {
const related = specified.filter(t => all.includes(t))
all = related.length ? related : all
return all.includes(query)
}
function predicate(tags?: string[]) {
if (!tags || !tags.length) {
return true
}
if (locale && !check(locales, tags, locale)) {
return false
}
return !variant || check(variants, tags, variant)
}
const options = {predicate: predicate, expand: false}
const canonical = fromCanonicalJson(content)
const reduced = canonical.reduce(options)
return [reduced, options.expand]
}
export function expandContent(content: JsonValue, sources: JsonValue[]): JsonValue {
function resolve(key: string): JsonValue {
for (const source of sources) {
const value = getPath(source, key)
if (value !== undefined) {
return value
}
}
return `$\{${key}\}`
}
function traverse(item: JsonValue): JsonValue {
if (item !== null) {
if (item instanceof CanonicalPrimitive) {
return item.resolve(resolve)
} else if (Array.isArray(item)) {
return item.map(i => traverse(i))
} else if (typeof item === 'object') {
const record: Record<string, JsonValue> = {}
for (const [k, v] of Object.entries(item)) {
record[k] = traverse(v)
}
return record
}
}
return item
}
return traverse(content)
}
// function getPath(obj: any, path: string, sep = '.'): any { // TODO REFACTOR THIS
// const keys = path.split(sep)
// let cur = obj
//
// for (const key of keys) {
// if (cur == null || typeof cur !== 'object') return undefined
// cur = cur[key]
// }
//
// return cur
// }
function toPath(path: string): Array<string | number> {
// Matches:
// - bare tokens: foo, bar
// - [123] numeric indices
// - ["str"] or ['str'] quoted keys
const re = /[^.[\]]+|\[(?:([0-9]+)|(["'])(.*?)\2)\]/g;
const tokens: Array<string | number> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(path))) {
if (m[1] !== undefined) {
tokens.push(Number(m[1])); // [123]
} else if (m[3] !== undefined) {
tokens.push(m[3]); // ["key"] / ['key']
} else {
tokens.push(m[0]); // bare token
}
}
return tokens;
}
function getPath(obj: unknown, path: string): JsonValue | undefined {
const steps = toPath(path);
let cur: any = obj;
for (const step of steps) {
if (cur == null) return undefined;
if (typeof step === 'number') {
if (!Array.isArray(cur) || step < 0 || step >= cur.length) return undefined;
cur = cur[step];
} else {
cur = cur[step];
}
}
return cur as JsonValue;
}

View File

@ -0,0 +1,92 @@
import {type Configuration} from './content-config'
import {getLayerDirectories} from '@nuxt/kit'
import {join, relative} from 'path'
import {existsSync} from 'fs'
import {readdir} from 'fs/promises'
// this solely exists because in prerender:routes composables or virtual modules are not available yet
export async function getConfig(): Promise<Configuration> {
const config = getLayerDirectories().map(d => join(d.root, 'content.config.ts')).find(f => existsSync(f))
if (config) {
const module = await import(config)
return module.default
}
return {}
}
export async function getRoutes(): Promise<Set<string>> {
async function find(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true , recursive: true })
return entries.filter(e => e.isFile() && e.name.endsWith('.vue')).map(e => relative(directory, join(e.parentPath, e.name)))
}
const files = await Promise.all(getLayerDirectories().map(d => find(d.appPages)))
return new Set(files.flatMap(fs => fs.map(f => f.slice(0, -4))))
}
export function getRouteShortcuts(routes: Set<string>): Record<string, string> {
const shorten = (r: string) => r.replace('[locale]/', '').replace('[variant]/', '')
const shortcuts = [...routes].map(r => [shorten(r), r] as [string, string])
const matches: Record<string, string[]> = {}
for (const [shortcut, route] of shortcuts) {
const list = matches[shortcut] ?? []
list.push(route)
matches[shortcut] = list
}
const unique: Record<string, string> = {}
for (const [shortcut, routes] of Object.entries(matches)) {
if (routes.length === 1) {
unique[shortcut] = routes[0]!
}
}
return unique
}
export async function getExpandedRoutes(config: Configuration): Promise<Set<string>> {
const locales = config.locale?.list.map(i => i.code) ?? []
const variants = config.variant?.list.map(i => i.code) ?? []
let combinations: [string | undefined, string | undefined][]
if (!locales.length && !variants.length) {
combinations = []
} else if (!locales.length) {
combinations = variants.map(v => [undefined, v])
} else if (!variants.length) {
combinations = locales.map(l => [l, undefined])
} else {
combinations = locales.flatMap(l => variants.map(v => [l, v] as [string, string]))
}
function expand(route: string, locale?: string, variant?: string): string {
let result = route
if (locale) {
result = result.replace('[locale]', locale)
}
if (variant) {
result = result.replace('[variant]', variant)
}
return result
}
const result = new Set<string>()
for (const unexpanded of await getRoutes()) {
for (const [locale, variant] of combinations) {
let expanded = expand(unexpanded, locale, variant)
result.add(expanded.endsWith('/index') ? expanded.slice(0, -6) : expanded)
}
}
return result
}

View File

@ -1,39 +0,0 @@
const MAGIC = 0x7f5a08f5
export interface TemplateString {
__magic: number
strings: TemplateStringsArray
variables: string[]
}
export function t(strings: TemplateStringsArray, ...variables: string[]): TemplateString {
return { __magic: MAGIC, strings: strings, variables: variables }
}
export function isTemplate(template: any): boolean {
return template !== null && typeof template === 'object' && template.__magic === MAGIC
}
export function expandTemplate(template: TemplateString, content: any): string {
const values = template.variables.map(path =>
path.split('.').reduce((acc, key) => {
if (acc && typeof acc === 'object' && key in acc) {
return acc[key]
}
return undefined
}, content)
)
let result = ''
for (let i = 0; i < template.strings.length; i++) {
result += template.strings[i]
if (i < values.length) {
result += values[i] ?? `\${${template.variables[i]}}`
}
}
return result
}

View File

@ -1,43 +0,0 @@
import type { TemplateString } from './content-template'
export type Translation = string | TemplateString
export type Translations = Translation | Record<string, Translation>
export interface Locale {
code: string
name: Translations
icon?: string
}
export interface Variant {
code: string
name: Translations
icon?: string
}
export interface ContentMatrix {
locale: {
default: string
list: Locale[]
cookieMaxAge: number
}
variant: {
default: string
list: Variant[]
cookieMaxAge: number
}
}
export interface BusinessInfo {
uid: string
name: Translations
operator: Translations
address: Translations
phone: Translations
email: Translations
vat: Translations
authority: Translations
membership: Translations
regulation: Translations
}

346
pnpm-lock.yaml generated
View File

@ -32,6 +32,9 @@ importers:
'@nuxt/image':
specifier: 1.11.0
version: 1.11.0(db0@0.3.4)(ioredis@5.8.0)(magicast@0.3.5)
'@nuxt/kit':
specifier: ^4.1.3
version: 4.1.3(magicast@0.3.5)
'@nuxt/ui':
specifier: ^4.0.1
version: 4.0.1(@babel/parser@7.28.4)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.8.0)(magicast@0.3.5)(typescript@5.9.2)(valibot@1.1.0(typescript@5.9.2))(vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.22(typescript@5.9.2)))(vue@3.5.22(typescript@5.9.2))(zod@4.1.11)
@ -53,9 +56,18 @@ importers:
packages/layers/content:
dependencies:
'@nuxt/kit':
specifier: ^4.1.3
version: 4.1.3(magicast@0.3.5)
esbuild:
specifier: ^0.25.11
version: 0.25.11
vue-router:
specifier: ^4.5.1
version: 4.5.1(vue@3.5.22(typescript@5.9.2))
yaml:
specifier: ^2.8.1
version: 2.8.1
devDependencies:
'@nuxt/eslint':
specifier: latest
@ -274,156 +286,312 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.25.11':
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.10':
resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.25.11':
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.10':
resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.25.11':
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.10':
resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.25.11':
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.10':
resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.25.11':
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.10':
resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.25.11':
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.10':
resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.25.11':
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.10':
resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.11':
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.10':
resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.25.11':
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.10':
resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.25.11':
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.10':
resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.25.11':
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.10':
resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.25.11':
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.10':
resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.25.11':
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.10':
resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.25.11':
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.10':
resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.25.11':
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.10':
resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.25.11':
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.10':
resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.25.11':
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.10':
resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.25.11':
resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.10':
resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.11':
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.10':
resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.25.11':
resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.10':
resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.11':
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.10':
resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.25.11':
resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.10':
resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.25.11':
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.10':
resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.25.11':
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.10':
resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.25.11':
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.10':
resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.25.11':
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.0':
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -679,6 +847,10 @@ packages:
resolution: {integrity: sha512-P5q41xeEOa6ZQC0PvIP7TSBmOAMxXK4qihDcCbYIJq8RcVsEPbGZVlidmxE6EOw1ucSyodq9nbV31FAKwoL4NQ==}
engines: {node: '>=18.12.0'}
'@nuxt/kit@4.1.3':
resolution: {integrity: sha512-WK0yPIqcb3GQ8r4GutF6p/2fsyXnmmmkuwVLzN4YaJHrpA2tjEagjbxdjkWYeHW8o4XIKJ4micah4wPOVK49Mg==}
engines: {node: '>=18.12.0'}
'@nuxt/schema@4.1.2':
resolution: {integrity: sha512-uFr13C6c52OFbF3hZVIV65KvhQRyrwp1GlAm7EVNGjebY8279QEel57T4R9UA1dn2Et6CBynBFhWoFwwo97Pig==}
engines: {node: ^14.18.0 || >=16.10.0}
@ -2478,6 +2650,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
esbuild@0.25.11:
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@ -3972,6 +4149,7 @@ packages:
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
@ -5021,81 +5199,159 @@ snapshots:
'@esbuild/aix-ppc64@0.25.10':
optional: true
'@esbuild/aix-ppc64@0.25.11':
optional: true
'@esbuild/android-arm64@0.25.10':
optional: true
'@esbuild/android-arm64@0.25.11':
optional: true
'@esbuild/android-arm@0.25.10':
optional: true
'@esbuild/android-arm@0.25.11':
optional: true
'@esbuild/android-x64@0.25.10':
optional: true
'@esbuild/android-x64@0.25.11':
optional: true
'@esbuild/darwin-arm64@0.25.10':
optional: true
'@esbuild/darwin-arm64@0.25.11':
optional: true
'@esbuild/darwin-x64@0.25.10':
optional: true
'@esbuild/darwin-x64@0.25.11':
optional: true
'@esbuild/freebsd-arm64@0.25.10':
optional: true
'@esbuild/freebsd-arm64@0.25.11':
optional: true
'@esbuild/freebsd-x64@0.25.10':
optional: true
'@esbuild/freebsd-x64@0.25.11':
optional: true
'@esbuild/linux-arm64@0.25.10':
optional: true
'@esbuild/linux-arm64@0.25.11':
optional: true
'@esbuild/linux-arm@0.25.10':
optional: true
'@esbuild/linux-arm@0.25.11':
optional: true
'@esbuild/linux-ia32@0.25.10':
optional: true
'@esbuild/linux-ia32@0.25.11':
optional: true
'@esbuild/linux-loong64@0.25.10':
optional: true
'@esbuild/linux-loong64@0.25.11':
optional: true
'@esbuild/linux-mips64el@0.25.10':
optional: true
'@esbuild/linux-mips64el@0.25.11':
optional: true
'@esbuild/linux-ppc64@0.25.10':
optional: true
'@esbuild/linux-ppc64@0.25.11':
optional: true
'@esbuild/linux-riscv64@0.25.10':
optional: true
'@esbuild/linux-riscv64@0.25.11':
optional: true
'@esbuild/linux-s390x@0.25.10':
optional: true
'@esbuild/linux-s390x@0.25.11':
optional: true
'@esbuild/linux-x64@0.25.10':
optional: true
'@esbuild/linux-x64@0.25.11':
optional: true
'@esbuild/netbsd-arm64@0.25.10':
optional: true
'@esbuild/netbsd-arm64@0.25.11':
optional: true
'@esbuild/netbsd-x64@0.25.10':
optional: true
'@esbuild/netbsd-x64@0.25.11':
optional: true
'@esbuild/openbsd-arm64@0.25.10':
optional: true
'@esbuild/openbsd-arm64@0.25.11':
optional: true
'@esbuild/openbsd-x64@0.25.10':
optional: true
'@esbuild/openbsd-x64@0.25.11':
optional: true
'@esbuild/openharmony-arm64@0.25.10':
optional: true
'@esbuild/openharmony-arm64@0.25.11':
optional: true
'@esbuild/sunos-x64@0.25.10':
optional: true
'@esbuild/sunos-x64@0.25.11':
optional: true
'@esbuild/win32-arm64@0.25.10':
optional: true
'@esbuild/win32-arm64@0.25.11':
optional: true
'@esbuild/win32-ia32@0.25.10':
optional: true
'@esbuild/win32-ia32@0.25.11':
optional: true
'@esbuild/win32-x64@0.25.10':
optional: true
'@esbuild/win32-x64@0.25.11':
optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.37.0(jiti@2.6.1))':
dependencies:
eslint: 9.37.0(jiti@2.6.1)
@ -5125,11 +5381,11 @@ snapshots:
dependencies:
'@nodelib/fs.walk': 3.0.1
ansis: 4.2.0
bundle-require: 5.1.0(esbuild@0.25.10)
bundle-require: 5.1.0(esbuild@0.25.11)
cac: 6.7.14
chokidar: 4.0.3
debug: 4.4.1
esbuild: 0.25.10
esbuild: 0.25.11
eslint: 9.37.0(jiti@2.6.1)
find-up: 7.0.0
get-port-please: 3.2.0
@ -5485,7 +5741,7 @@ snapshots:
'@nuxt/devtools-kit': 2.6.5(magicast@0.3.5)(vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))
'@nuxt/eslint-config': 1.9.0(@typescript-eslint/utils@8.40.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.2))(@vue/compiler-sfc@3.5.22)(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.2)
'@nuxt/eslint-plugin': 1.9.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.2)
'@nuxt/kit': 4.1.2(magicast@0.3.5)
'@nuxt/kit': 4.1.3(magicast@0.3.5)
chokidar: 4.0.3
eslint: 9.37.0(jiti@2.6.1)
eslint-flat-config-utils: 2.1.4
@ -5560,7 +5816,7 @@ snapshots:
'@iconify/utils': 3.0.2
'@iconify/vue': 5.0.0(vue@3.5.22(typescript@5.9.2))
'@nuxt/devtools-kit': 2.6.5(magicast@0.3.5)(vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))
'@nuxt/kit': 4.1.2(magicast@0.3.5)
'@nuxt/kit': 4.1.3(magicast@0.3.5)
consola: 3.4.2
local-pkg: 1.1.2
mlly: 1.8.0
@ -5668,6 +5924,33 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/kit@4.1.3(magicast@0.3.5)':
dependencies:
c12: 3.3.0(magicast@0.3.5)
consola: 3.4.2
defu: 6.1.4
destr: 2.0.5
errx: 0.1.0
exsolve: 1.0.7
ignore: 7.0.5
jiti: 2.6.1
klona: 2.0.6
mlly: 1.8.0
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.3.0
rc9: 2.1.2
scule: 1.3.0
semver: 7.7.2
std-env: 3.9.0
tinyglobby: 0.2.15
ufo: 1.6.1
unctx: 2.4.1
unimport: 5.4.1
untyped: 2.0.0
transitivePeerDependencies:
- magicast
'@nuxt/schema@4.1.2':
dependencies:
'@vue/shared': 3.5.22
@ -5703,7 +5986,7 @@ snapshots:
'@internationalized/number': 3.6.5
'@nuxt/fonts': 0.11.4(db0@0.3.4)(ioredis@5.8.0)(magicast@0.3.5)(vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))
'@nuxt/icon': 2.0.0(magicast@0.3.5)(vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.2))
'@nuxt/kit': 4.1.2(magicast@0.3.5)
'@nuxt/kit': 4.1.3(magicast@0.3.5)
'@nuxt/schema': 4.1.2
'@nuxtjs/color-mode': 3.5.2(magicast@0.3.5)
'@standard-schema/spec': 1.0.0
@ -5739,8 +6022,8 @@ snapshots:
tinyglobby: 0.2.15
typescript: 5.9.2
unplugin: 2.3.10
unplugin-auto-import: 20.2.0(@nuxt/kit@4.1.2(magicast@0.3.5))(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.2)))
unplugin-vue-components: 29.1.0(@babel/parser@7.28.4)(@nuxt/kit@4.1.2(magicast@0.3.5))(vue@3.5.22(typescript@5.9.2))
unplugin-auto-import: 20.2.0(@nuxt/kit@4.1.3(magicast@0.3.5))(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.2)))
unplugin-vue-components: 29.1.0(@babel/parser@7.28.4)(@nuxt/kit@4.1.3(magicast@0.3.5))(vue@3.5.22(typescript@5.9.2))
vaul-vue: 0.4.1(reka-ui@2.5.1(typescript@5.9.2)(vue@3.5.22(typescript@5.9.2)))(vue@3.5.22(typescript@5.9.2))
vue-component-type-helpers: 3.1.0
optionalDependencies:
@ -5799,7 +6082,7 @@ snapshots:
consola: 3.4.2
cssnano: 7.1.1(postcss@8.5.6)
defu: 6.1.4
esbuild: 0.25.10
esbuild: 0.25.11
escape-string-regexp: 5.0.0
exsolve: 1.0.7
get-port-please: 3.2.0
@ -6975,9 +7258,9 @@ snapshots:
dependencies:
run-applescript: 7.1.0
bundle-require@5.1.0(esbuild@0.25.10):
bundle-require@5.1.0(esbuild@0.25.11):
dependencies:
esbuild: 0.25.10
esbuild: 0.25.11
load-tsconfig: 0.2.5
c12@3.3.0(magicast@0.3.5):
@ -7420,6 +7703,35 @@ snapshots:
'@esbuild/win32-ia32': 0.25.10
'@esbuild/win32-x64': 0.25.10
esbuild@0.25.11:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.11
'@esbuild/android-arm': 0.25.11
'@esbuild/android-arm64': 0.25.11
'@esbuild/android-x64': 0.25.11
'@esbuild/darwin-arm64': 0.25.11
'@esbuild/darwin-x64': 0.25.11
'@esbuild/freebsd-arm64': 0.25.11
'@esbuild/freebsd-x64': 0.25.11
'@esbuild/linux-arm': 0.25.11
'@esbuild/linux-arm64': 0.25.11
'@esbuild/linux-ia32': 0.25.11
'@esbuild/linux-loong64': 0.25.11
'@esbuild/linux-mips64el': 0.25.11
'@esbuild/linux-ppc64': 0.25.11
'@esbuild/linux-riscv64': 0.25.11
'@esbuild/linux-s390x': 0.25.11
'@esbuild/linux-x64': 0.25.11
'@esbuild/netbsd-arm64': 0.25.11
'@esbuild/netbsd-x64': 0.25.11
'@esbuild/openbsd-arm64': 0.25.11
'@esbuild/openbsd-x64': 0.25.11
'@esbuild/openharmony-arm64': 0.25.11
'@esbuild/sunos-x64': 0.25.11
'@esbuild/win32-arm64': 0.25.11
'@esbuild/win32-ia32': 0.25.11
'@esbuild/win32-x64': 0.25.11
escalade@3.2.0: {}
escape-html@1.0.3: {}
@ -8364,7 +8676,7 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
dot-prop: 9.0.0
esbuild: 0.25.10
esbuild: 0.25.11
escape-string-regexp: 5.0.0
etag: 1.8.1
exsolve: 1.0.7
@ -8505,7 +8817,7 @@ snapshots:
destr: 2.0.5
devalue: 5.3.2
errx: 0.1.0
esbuild: 0.25.10
esbuild: 0.25.11
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
exsolve: 1.0.7
@ -9628,7 +9940,7 @@ snapshots:
unplugin: 2.3.10
unplugin-utils: 0.3.0
unplugin-auto-import@20.2.0(@nuxt/kit@4.1.2(magicast@0.3.5))(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.2))):
unplugin-auto-import@20.2.0(@nuxt/kit@4.1.3(magicast@0.3.5))(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.9.2))):
dependencies:
local-pkg: 1.1.2
magic-string: 0.30.19
@ -9637,7 +9949,7 @@ snapshots:
unplugin: 2.3.10
unplugin-utils: 0.3.0
optionalDependencies:
'@nuxt/kit': 4.1.2(magicast@0.3.5)
'@nuxt/kit': 4.1.3(magicast@0.3.5)
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.2))
unplugin-utils@0.2.5:
@ -9650,7 +9962,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.3
unplugin-vue-components@29.1.0(@babel/parser@7.28.4)(@nuxt/kit@4.1.2(magicast@0.3.5))(vue@3.5.22(typescript@5.9.2)):
unplugin-vue-components@29.1.0(@babel/parser@7.28.4)(@nuxt/kit@4.1.3(magicast@0.3.5))(vue@3.5.22(typescript@5.9.2)):
dependencies:
chokidar: 3.6.0
debug: 4.4.3
@ -9663,7 +9975,7 @@ snapshots:
vue: 3.5.22(typescript@5.9.2)
optionalDependencies:
'@babel/parser': 7.28.4
'@nuxt/kit': 4.1.2(magicast@0.3.5)
'@nuxt/kit': 4.1.3(magicast@0.3.5)
transitivePeerDependencies:
- supports-color
@ -9863,7 +10175,7 @@ snapshots:
vite@7.1.9(@types/node@22.15.3)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.10
esbuild: 0.25.11
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6