Compare commits

...

2 Commits

Author SHA1 Message Date
0dc24c4db7 Extend ux layer and overhaul panoramablick-saalbach.at
Some checks failed
Build and deploy updated apps / Build & deploy (push) Failing after 50s
2025-11-21 21:17:52 +01:00
7d73f2b784 Add comment about icons 2025-10-22 20:25:06 +02:00
192 changed files with 8058 additions and 3853 deletions

4
.npmrc
View File

@ -0,0 +1,4 @@
shared-workspace-lockfile=true # all apps/packages share the same root lockfile
frozen-lockfile=true # pnpm install updates the lockfile, the CI uses pnpm install --frozen-lockfile which fails if one package.json is out of sync
prefer-workspace-packages=true # if a package like @acme/utils exists locally use this one, not the one from a registry
save-exact = true # never add ^ or ~

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
.nuxt
.output
node_modules
dist

View File

@ -1,3 +1,56 @@
## Good to know
- Using custom tags directly inside the <template> tag will fuck up transitions, always wrap within an extra div
- Using custom tags directly inside the <template> tag will fuck up transitions, always wrap within an extra div
## Monorepo setup
### Version management
The following principes apply:
- The whole monorepo has a single pnpm-lock.yaml
- Dependencies are locked to exact versions
- CI build happens with `pnpm install --frozen-lockfile` (fails if out of sync with package.json)
- Use only the `pnpm manage` tool to handle dependencies
Declarations in `package.json` are as follows (enforce with pnpm manage)
- Regular dependencies are the ones needed at runtime. Dependencies of a library and consumer are independent, e.g. if layer specifies yaml and the app also then two independent yaml installations are used.
- Dev dependencies are purely needed for development/build of the current package. If a layer uses a dependency for e.g. a build hook that is used in a consumer then declare it a regular dependency.
- Peer dependencies are for library packages that request the final consumer to provide a shared framework (e.g. nuxt, vue) or extend that shared framework (e.g. @nuxt/icon). A peer dependency must always also be listed as dev dependency in the declaring library package. If a library depends on another library it should re-declare the peer (& associated dev) dependencies.
- The final consumer (app) declares all peer dependencies from parent packages as prod dependencies
## Tailwind setup
Dont use @nuxtjs/tailwindcss. Follow the instructions at https://tailwindcss.com/docs/installation/framework-guides/nuxt (actually adding the tailwind plugin in nuxt.config.ts isnt necessary it seems)
tailwind.config.js isnt a thing anymore according to docs, everything can be configured in the css file: https://tailwindcss.com/blog/tailwindcss-v4#css-first-configuration
--spacing-* isnt a thing anymore, only a single --spacing (defaults to 0.25rem) is used now. It scales basically everything, e.g. p-<number> means padding: calc(var(--spacing) * <number>);
Utilities are only generated if detected by tailwind, need to use @source or import "tailwind" source("dir-to-scan"), need to check how this works in apps
tailwind classes like p-* are only supposed to take integers or .5 steps, nothing else (and this only for backwards compatibility)
Tailwind scans source files for used classes and only generates those. Doesnt work too well apparently with nuxt, so in the .css write @source to specify folders to scan
This especially applies to apps that use layers, where the layers export components that themselves use some classes, so best do @source the layer too.
## Ellipsis
set width:100% on all parents (or somehow a defined with)
apply truncate to direct parent of text (e.g. <span class=truncate)
within flexbox or grid set min-w-0 for parent if it doesnt work. if inside a flex-col also set w-full to the span itself in addition to truncate
## Mobile menu background scale
set data-vaul-drawer-wrapper e.g. on whatever should be zoomed out (eg <main data-vaul-drawer-wrapper)
## Vue component props
If a boolean is specified in defineProps as flag?: boolean and its not passed vue makes it false, not undefined!
explicitely set useDefaults
## HTML tags
footer (outside main) is furniture, dont use h1 or so, just use p and style it like it was e.g. heading
each route should have exactly one h1. can be in header navbar if its dynamic, or somewhere hidden, but it should be there.
## DOES NOT HAVE A SINGLE ROOT ERRRO
For the love of god never put a html comment inside a <template> section

View File

@ -1,9 +0,0 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'gimblet',
secondary: 'stone',
neutral: 'sandstone'
}
},
})

View File

@ -0,0 +1,139 @@
export default defineAppConfig({
ui: {
link: {
variants: {
active: {
true: 'text-primary',
false: 'text-neutral',
},
},
compoundVariants: [
{
active: false,
disabled: false,
class: 'hover:text-primary',
},
],
},
colors: {
primary: 'gimblet',
secondary: 'stone',
neutral: 'sandstone',
},
separator: {
defaultVariants: {
color: 'primary',
},
},
button: {
defaultVariants: {
size: 'lg',
},
},
},
ux: {
typography: {
variants: {
type: {
page: {
base: 'text-5xl',
},
link: {
base: 'text-base',
},
},
color: {
neutral: {
base: 'text-neutral-700',
},
},
},
},
textIcons: {
slots: {
base: 'fgap-1',
icons: 'fgap-1 text-neutral',
},
},
containerHeroContentButton: {
slots: {
grid: 'rgap-2',
},
},
scaffoldContent: {
slots: {
content: 'rpx-2',
},
},
scaffoldIconButton: {
base: 'text-neutral-700',
},
scaffoldSimpleHeader: {
slots: {
background: 'bg-neutral-200 shadow',
container: 'text-neutral-700 rpy-1.5 fgap-2',
brand: 'fgap-2',
navigationList: 'fgap-2',
separator: '',
},
},
scaffoldHeaderNew: {
slots: {
background: 'w-full h-full bg-neutral-200',
container: 'rpy-1.5 fgap-2',
switchers: 'fgap-2',
separatorList: 'fgap-2',
},
},
scaffoldNavigationLinkList: {
slots: {
base: '',
list: 'fgap-2',
item: '',
link: '',
},
},
scaffoldMenuModal: {
slots: {
wrapper: '',
overlay: 'bg-black/75',
content: 'rpy-2 bg-elevated border-neutral-200 rm-2',
},
},
containerButtons: {
slots: {
base: 'fgap-1',
},
},
contactDetailGroup: {
slots: {
base: 'fgap-0.5',
},
},
footerCopyrightLegal: {
slots: {
base: 'fgap-0.5',
links: 'fgap-1',
},
},
containerPair: {
slots: {
container: 'rgap-2',
},
},
gridIconLabelDetail: {
slots: {
base: 'rgap-2',
item: 'rgap-1',
icon: 'mt-0.75 text-primary',
label: '',
detail: '',
},
},
contactForm: {
slots: {
base: 'fgap-1',
},
},
},
})

View File

@ -1,7 +1,7 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<UApp :toaster="{ position: 'top-center' }">
<NuxtLayout />
</UApp>
</template>
<script setup lang="ts">
@ -17,9 +17,6 @@ onMounted(() => {
timeout: 8000,
color: 'primary',
icon: 'i-heroicons-shield-check',
ui: {
position: "bottom-center"
}
})
shown.value = true
}
@ -35,4 +32,4 @@ onMounted(() => {
.page-leave-to {
opacity: 0;
}
</style>
</style>

View File

@ -1,32 +1,8 @@
@import "tailwindcss";
@import "@nuxt/ui";
.red {
border: 1px solid red;
}
.green {
border: 1px solid green;
}
@layer utilities {
.text-outline {
-webkit-text-stroke: 1px black;
color: gold;
}
.pxr {
@apply px-4 sm:px-6 lg:px-8;
}
.pyr {
@apply py-4 sm:py-6 lg:py-8;
}
.pr {
@apply p-4 sm:p-6 lg:p-8;
}
}
@source "../..";
@source "../../../../../packages/layers/ux/app";
@theme static {
--color-gimblet: oklch(74.37% 0.06969 91.48);
@ -106,6 +82,6 @@
}
html {
scroll-behavior: smooth;
/*scroll-behavior: smooth;*/
/*font-family: Roboto, Arial, sans-serif;*/
}

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
const { g, p } = useContentInjected()
defineProps<{ apartment: any; index: number }>()
</script>
<template>
<div :id="g.apartments[index].id" class="rp-2">
<XContainerHeroContentButtons hero="top" buttons="bottom">
<template #hero>
<UCarousel
v-slot="{ item }"
loop
arrows
dots
autoplay
:items="apartment.images"
:ui="{
root: 'mb-12',
container: '',
item: 'basis-auto transition-opacity',
controls: 'relative translate-y-8',
arrows: '',
dots: 'absolute inset-x-0 -bottom-0 flex flex-wrap items-center justify-center gap-3 translate-y-0.5',
prev: 'start-0 sm:-start-0 -top-4 -translate-y-0',
next: 'end-0 sm:-end-0 -top-4 -translate-y-0',
dot: 'w-3 @md:w-4 h-1',
}"
>
<div class="h-60 flex flex-row justify-center">
<NuxtImg :src="item" class="h-full" />
</div>
</UCarousel>
</template>
<template #buttons>
<XContainerButtons align="right" :stretch="false">
<AppButton type="contact" variant="outline" />
<AppButton type="book" color="primary" />
</XContainerButtons>
</template>
<div class="flex flex-col">
<XText as="section" :text="apartment.title" />
<XText as="paragraph" :text="apartment.subtitle" />
<XGridIconLabelDetail
:items="apartment.features"
:ux="{ icon: 'mt-0', texts: 'justify-center', label: 'font-medium' }"
/>
</div>
</XContainerHeroContentButtons>
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
const props = defineProps<{
type: string
}>()
const { l, p } = useContentInjected()
const details = computed(() => l.value[props.type])
</script>
<template>
<UButton
:to="details.external ? details.link : p(details.link)"
:target="details.external ? '_blank' : undefined"
variant="solid"
:trailing-icon="details.icon"
:external="details.external"
>
{{ details.label }}
</UButton>
</template>

View File

@ -0,0 +1,44 @@
apartments:
icon: 'heroicons:arrow-right'
link: '/[locale]/[variant]/apartments'
label:
$de: Apartments
$en: Apartments
book:
icon: 'heroicons:calendar-days'
link: '/[locale]/book'
label:
$de: Buchen
$en: Book
here:
icon: 'heroicons:calendar-days'
link: '/[locale]/book'
label:
$de: Hier
$en: Here
contact:
icon: 'heroicons:envelope'
link: '/[locale]/contact'
label:
$de: Kontakt
$en: Contact
hosts:
icon: 'heroicons:arrow-right'
link: '/[locale]/contact#hosts'
label:
$de: Die Gastgeber
$en: The Hosts
location:
icon: 'heroicons:arrow-right'
link: ${business.location.link}
external: true
label:
$de: Maps Öffnen
$en: Open Maps
bruendl:
icon: 'heroicons:arrow-right'
link: ${bruendllink}
external: true
label:
$de: Reservieren
$en: Make a Reservation

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
const { l } = useContentInjected()
</script>
<template>
<XScaffoldContent>
<template #background>
<div class="w-full h-full bg-neutral-100 border-t border-neutral-200" />
</template>
<div class="flex flex-col fgap-2 rpy-1.5">
<XContainerHeroContentButtons
hero="left"
buttons="right"
break="sm"
buttons-break="lg"
:ux="{ grid: 'fgap-2' }"
>
<template #hero>
<div class="w-full h-full flex justify-center items-center">
<NuxtImg class="self-center object-cover rounded-full w-20" :src="l.avatar" />
</div>
</template>
<template #buttons>
<XContainerButtons axis="row" break="lg" :stretch="true">
<AppButton type="contact" color="primary" />
<AppButton type="book" color="primary" />
</XContainerButtons>
</template>
<div class="flex flex-col fgap-2">
<div class="flex flex-col">
<XText as="paragraph" visual="label" :text="l.questions" />
<XText as="paragraph" visual="detail" :text="l.prompt" :margin="true" />
</div>
<XContactDetailGroup :names="['phone', 'email', 'location']" />
</div>
</XContainerHeroContentButtons>
<USeparator class="w-full" orientation="horizontal" color="neutral" />
<XFooterCopyrightLegal />
</div>
</XScaffoldContent>
</template>

View File

@ -0,0 +1,7 @@
avatar: "/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!

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
const { l } = useContentInjected()
</script>
<template>
<div class="flex flex-col rp-2 rgap-2">
<XText as="section" :text="l.title" />
<XGridIconLabelDetail :items="l.items" />
</div>
</template>

View File

@ -0,0 +1,104 @@
title:
$de: Darauf können Sie sich freuen!
$en: Thats something to look forward to!
description:
$de: Was Sie in Ihrem Urlaub im ${business.name} erwartet.
$en: What awaits you during your stay at ${business.name}.
items:
- icon: 'lucide: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: 'lucide: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: 'lucide: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: 'lucide: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: 'lucide:bike'
$winter: 'lucide: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: 'lucide:flame-kindling'
label:
$de: Grillplatz
$en: Barbecue
detail:
$de: Kugelgrill und Smoker leihbar
$en: Kettle grill and smoker available for rent
- icon: 'lucide:mountain'
label:
$de: Exzellente Lage
$en: Excellent location
detail:
$de: 5 Min. zum Ortskern
$en: 5 minutes to the town center
- icon: 'lucide: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: 'lucide: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: 'lucide: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: 'lucide: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: 'lucide: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

@ -0,0 +1,41 @@
<script setup lang="ts">
const props = defineProps<{
class?: string
ux?: any
mode: 'flat' | 'card'
}>()
const classes = useStyling(
'appSection',
{
slots: {
background: 'w-full h-full',
container: '',
},
variants: {
mode: {
flat: {
background: 'bg-neutral-100 border-t border-b border-neutral-200',
container: 'rpy-3',
},
card: {
container: 'rmy-3 bg-neutral-100 border border-neutral-200',
},
},
},
},
props
)
</script>
<template>
<XScaffoldContent>
<template #background>
<div :class="classes.background" />
</template>
<section :class="classes.container">
<slot />
</section>
</XScaffoldContent>
</template>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
const { l } = useContentInjected()
</script>
<template>
<div class="flex flex-row justify-between rgap-1.5">
<XImageTransformed :src="l.left" :ux="{ image: 'w-full aspect-3/5' }" clip />
<XImageTransformed :src="l.right" class="mt-8" :ux="{ image: 'w-full aspect-3/5' }" clip />
</div>
</template>

View File

@ -0,0 +1,2 @@
left: /ap.webp
right: /sauna.webp

View File

@ -0,0 +1,100 @@
business:
uid: test #panoramablick-saalbach.at
name:
$de: Landhaus Panoramablick
$en: Guesthouse Panoramablick
operator: Monika & Norbert Pail
location:
address: Unterer Ronachweg 731, 5753 Saalbach, AT
link: 'https://maps.app.goo.gl/JQ2mcgFxDcLAdGmV6'
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
bruendllink:
$de: 'https://www.bruendl.at/de/rent/online-reservierung?partnerId=panoramablick-saalbach'
$en: 'https://www.bruendl.at/en/rent/online-rental?partnerId=panoramablick-saalbach'
contact:
icons:
location:
- 'heroicons:map-pin'
phone:
- 'heroicons:phone'
- 'uil:whatsapp'
email:
- 'heroicons:envelope'
button:
apartments:
icon: 'heroicons:arrow-right'
label:
$de: Apartments
$en: Apartments
book:
icon: 'heroicons:calendar-days'
label:
$de: Buchen
$en: Book
contact:
icon: 'heroicons:envelope'
label:
$de: Kontakt
$en: Contact
hosts:
icon: 'heroicons:arrow-right'
label:
$de: Die Gastgeber
$en: The Hosts
map:
icon: 'heroicons:arrow-right'
label:
$de: Maps Öffnen
$en: Open Maps
scaffold:
copyright:
from: 2024
home:
icon: 'heroicons:home'
link: '/[locale]/[variant]'
text: ${business.name}
mobile:
open:
icon: 'heroicons:bars-3'
close:
icon: 'heroicons:x-mark'
legal:
- link: '/[locale]/legal#imprint'
text:
$de: Impressum
$en: Imprint
- link: '/[locale]/legal#privacy'
text:
$de: Datenschutz
$en: Privacy
- link: '/[locale]/legal#accessibility'
text:
$de: Barrierefreiheit
$en: Accessibility
pages:
- link: '/[locale]/[variant]'
name: home
text:
$de: Willkommen
$en: Welcome
- link: 'apartments'
text:
$de: Apartments & Preise
$en: Apartments & Prices
- link: 'book'
text:
$de: Buchen
$en: Book
- link: 'contact'
text:
$de: Kontakt
$en: Contact

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
const { g, l, p } = useContentInjected()
const separator = resolveComponent('XSeparator')
</script>
<template>
<!-- <XScaffoldSimpleMobileSidebar-->
<!-- :home-props="{ size: 'xl' }"-->
<!-- :close-props="{ size: 'xl' }"-->
<!-- :nav-list-props="{ linkProps: { visual: 'section', color: 'none' } }"-->
<!-- >-->
<!-- <template #footer>-->
<!-- <XFooterCopyrightLegal />-->
<!-- </template>-->
<!-- </XScaffoldSimpleMobileSidebar>-->
<XScaffoldMenuModal :full-height="false" horizontal="fill" vertical="top" position="below">
<XScaffoldContent>
<XScaffoldNavigationLinkList orientation="vertical" :link-props="{ class: 'text-lg' }" />
</XScaffoldContent>
</XScaffoldMenuModal>
<XScaffoldGridLayout>
<template #header>
<XScaffoldHeaderNew
mobile="left"
home="break"
main="left"
:separator-list-props="{
separator: separator,
separatorProps: { orientation: 'vertical' },
}"
>
<template #home><XScaffoldNavigationHomeButton size="xl" /></template>
<template #mobile><XScaffoldMenuToggleButton size="xl" no-close /></template>
<template #navigation>
<XScaffoldNavigationLinkList
orientation="horizontal"
:exclude="['home']"
:link-props="{ class: 'text-lg' }"
/>
</template>
<template #switchers>
<XContentLocaleSwitcher class="text-lg" />
<XContentVariantSwitcher class="text-lg" />
</template>
<XScaffoldNavigationHomeLink visual="heading" :margin="false" />
</XScaffoldHeaderNew>
<!-- <XScaffoldHeaderSimple-->
<!-- :brand-props="{ visual: 'heading', color: 'primary' }"-->
<!-- :link-props="{ visual: 'paragraph', color: 'none' }"-->
<!-- :home-props="{ size: 'xl' }"-->
<!-- :mobile-props="{ size: 'xl' }"-->
<!-- >-->
<!-- <template #locale>-->
<!-- <XContentLocaleSwitcher />-->
<!-- </template>-->
<!-- <template #variant>-->
<!-- <XContentVariantSwitcher />-->
<!-- </template>-->
<!-- </XScaffoldHeaderSimple>-->
</template>
<NuxtPage />
<template #footer>
<AppFooter />
</template>
</XScaffoldGridLayout>
</template>

View File

@ -1,29 +1,35 @@
class Text {
constructor(readonly $de: string, readonly $en: string) {
}
constructor(
readonly $de: string,
readonly $en: string
) {}
}
class Feature {
readonly label: Text | string
constructor(readonly icon: string, readonly de: string, readonly en?: 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')
new Feature('lucide:shower-head', 'Badezimmer mit Dusche', 'Bathroom with shower'),
new Feature('lucide:toilet', 'Bad und WC getrennt', 'Bathroom and toilet in separate rooms'),
new Feature('lucide:wifi', 'Schnelles WLAN', 'High-speed WiFi'),
new Feature('lucide:tv', 'Fernseher', 'TV'),
new Feature('lucide:microwave', 'Mikrowelle', 'Microwave'),
new Feature('lucide:microwave', 'Backrohr', 'Oven'),
new Feature('lucide:bubbles', 'Geschirrspüler', 'Dishwasher'),
new Feature('lucide:coffee', 'Kaffeemaschine', 'Coffee maker'),
new Feature('lucide:coffee', 'Wasserkocher', 'Electric water boiler'),
new Feature('lucide:bed', 'Bettwäsche', 'Bed sheets'),
new Feature('lucide:layers', 'Handtücher', 'Towels'),
new Feature('lucide:wind', 'Haarföhn', 'Hairdryer'),
]
class Apartment {
@ -31,16 +37,22 @@ class Apartment {
readonly subtitle: Text
readonly features: Feature[]
constructor(index: number, readonly de: string, readonly en: string, specifics: Feature[], readonly images: string[]) {
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²'),
new Feature('lucide:coins', '${apartments[' + index.toString() + '].price.text}'),
new Feature('lucide:users', '${apartments[' + index.toString() + '].capacity.text}'),
new Feature('lucide:scaling', '${apartments[' + index.toString() + '].size} m²'),
...specifics,
...features
...features,
]
}
}
@ -52,9 +64,17 @@ export default {
'${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'),
new Feature(
'lucide:mountain',
'Terrasse mit idyllischem Ausblick',
'Terrace with idyllic view'
),
new Feature('lucide:bed-double', 'Schlafzimmer mit Doppelbett', 'Bedroom with double bed'),
new Feature(
'lucide:sofa',
'Wohnküche mit ausziehbarem Schlafsofa',
'Living/kitchen area with sofa bed'
),
],
[
'/apartments/1/1.webp',
@ -67,7 +87,7 @@ export default {
'/apartments/1/8.webp',
'/apartments/1/9.webp',
'/apartments/1/10.webp',
'/apartments/1/11.webp'
'/apartments/1/11.webp',
]
),
new Apartment(
@ -75,10 +95,22 @@ export default {
'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'),
new Feature(
'lucide:mountain',
'Balkon mit idyllischem Ausblick',
'Balcony with idyllic view'
),
new Feature(
'lucide:bed-double',
'Schlafzimmer 1 mit Doppelbett',
'Bedroom 1 with double bed'
),
new Feature(
'lucide:bed-double',
'Schlafzimmer 2 mit Doppelbett & Schlafsofa',
'Bedroom 2 with double bed & sofa bed'
),
new Feature('lucide:sofa', 'Wohnküche', 'Living/kitchen area'),
],
[
'/apartments/2/1.webp',
@ -93,7 +125,7 @@ export default {
'/apartments/2/10.webp',
'/apartments/2/11.webp',
'/apartments/2/12.webp',
'/apartments/2/13.webp'
'/apartments/2/13.webp',
]
),
new Apartment(
@ -101,10 +133,22 @@ export default {
'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'),
new Feature(
'lucide:mountain',
'Balkon mit idyllischem Ausblick',
'Balcony with idyllic view'
),
new Feature(
'lucide:bed-double',
'Schlafzimmer 1 mit Doppelbett',
'Bedroom 1 with double bed'
),
new Feature(
'lucide:bed-double',
'Schlafzimmer 2 mit Doppelbett & Schlafsofa',
'Bedroom 2 with double bed & sofa bed'
),
new Feature('lucide:sofa', 'Wohnküche', 'Living/kitchen area'),
],
[
'/apartments/3/1.webp',
@ -120,8 +164,8 @@ export default {
'/apartments/3/11.webp',
'/apartments/3/12.webp',
'/apartments/3/13.webp',
'/apartments/3/14.webp'
'/apartments/3/14.webp',
]
)
]
}
),
],
}

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
useSeoLinking()
const { l } = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>
<template>
<XScaffoldGridPage>
<AppSection mode="flat">
<XContainerPair fractions="1/2" break="2xl" :ux="{ left: 'flex items-center' }">
<template #left>
<AppShowcase />
</template>
<template #right>
<AppHighlights />
</template>
</XContainerPair>
</AppSection>
<AppSection mode="card" v-for="(apartment, index) in l.list" :key="index">
<AppApartment :apartment="apartment" :index="index" />
</AppSection>
</XScaffoldGridPage>
</template>

View File

@ -10,14 +10,3 @@ meta:
$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

@ -0,0 +1,172 @@
<script setup lang="ts">
useSeoLinking()
const { g, l, p } = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>
<template>
<XScaffoldGridPage>
<template #hero>
<XScaffoldContent class="rpy-4">
<div class="w-full flex flex-col rgap-3">
<XContainerPair fractions="3/1" break="lg">
<template #left>
<div class="w-full max-w-[55rem] flex flex-col">
<XText class="text-white" as="page" :text="l.hero.title" />
<XText
class="text-white"
as="subheading"
:text="l.hero.description"
:margin="false"
/>
</div>
</template>
<template #right v-if="l.hero.joker?.show">
<XImageTransformed
:src="l.hero.joker.image"
:to="l.hero.joker.link"
mode="center"
:ux="{
link: 'rotate-15 rmx-2 rmt-2 rmb-2',
image: 'w-25 rounded-[0.5rem]',
}"
/>
</template>
</XContainerPair>
<XContainerButtons axis="row" align="left" :stretch="true" class="max-w-[55rem]">
<AppButton type="apartments" color="primary" size="xl" />
<AppButton type="book" color="secondary" size="xl" />
<AppButton type="contact" color="secondary" size="xl" />
</XContainerButtons>
</div>
</XScaffoldContent>
</template>
<template #hero-background>
<XImagePanoramaSlider
mode="fade"
:delay="5000"
:duration="150"
:images="l.hero.images"
:ux="{
overlay: 'bg-black/60',
}"
/>
</template>
<AppSection mode="flat">
<XContainerPair
fractions="1/2"
break="2xl"
:ux="{ left: 'flex items-center', right: 'flex items-center' }"
>
<template #left>
<AppShowcase />
</template>
<template #right>
<div class="flex flex-col items-start">
<XText as="section" :text="l.highlight.title" />
<XText as="paragraph" :text="l.highlight.description" />
<AppButton type="hosts" color="primary" />
</div>
</template>
</XContainerPair>
</AppSection>
<AppSection mode="card">
<XContainerPair
fractions="1/1"
break="xl"
:ux="{ container: '!gap-0', left: 'flex items-center' }"
>
<template #left>
<div class="w-full flex flex-col items-start rp-2">
<XText as="section" :text="l.location.title" />
<XText as="paragraph" :text="l.location.description" />
<AppButton type="location" color="primary" />
</div>
</template>
<template #right>
<XImageTransformed :src="l.location.image" :to="l.location.link" mode="cover" clip />
</template>
</XContainerPair>
</AppSection>
<AppSection mode="flat">
<div class="w-full flex flex-col">
<XText as="section" :text="l.apartments.title" />
<XText as="paragraph" :text="l.apartments.description" :margin="false" />
<div class="flex justify-center">
<div class="flex flex-row flex-wrap rgap-1 rmt-2">
<XImageTransformed
v-for="(thumbnail, index) in l.apartments.thumbnails"
:key="index"
:src="thumbnail"
:to="p('apartments#' + g.apartments[index].id).value"
:ux="{ base: 'flex-1 min-w-60 aspect-4/3 md:aspect-2/3 max-h-[20rem] h-auto' }"
clip
>
<div class="w-full h-full flex justify-center items-center bg-black/50">
<ul class="text-center flex flex-col fgap-1">
<li>
<XText as="label" class="text-white" :text="g.apartments[index].title" />
</li>
<li>
<XText
as="label"
class="text-white"
:text="g.apartments[index].capacity.text"
/>
</li>
<li>
<XText
as="label"
class="text-white"
:text="g.apartments[index].price.text"
:margin="false"
/>
</li>
</ul>
</div>
</XImageTransformed>
</div>
</div>
</div>
</AppSection>
<AppSection mode="card">
<AppHighlights />
</AppSection>
<AppSection mode="card">
<XContainerPair
fractions="1/2"
break="2xl"
:ux="{ container: '!gap-0', left: 'flex items-center' }"
>
<template #left>
<div class="w-full flex flex-col items-start rp-2">
<XText as="section" :text="l.bruendl.title" />
<XText as="paragraph" :text="l.bruendl.description" />
<AppButton type="bruendl" color="primary" />
</div>
</template>
<template #right>
<NuxtLink :to="l.bruendl.link" target="_blank" external>
<XImageTransformed :src="l.bruendl.banner" mode="center" clip />
</NuxtLink>
</template>
</XContainerPair>
</AppSection>
</XScaffoldGridPage>
</template>

View File

@ -10,13 +10,19 @@ meta:
$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:
hero:
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.
joker:
show:
$summer: true
$winter: false
image: /joker-card.webp
link: 'https://www.saalbach.com/de/sommer/joker-card'
images:
$summer:
- /landing/2.webp
@ -26,9 +32,6 @@ welcome:
$winter:
- /landing/w1.webp
- /landing/w2.webp
joker:
$summer: true
$winter: false
highlight:
title:
@ -39,9 +42,6 @@ highlight:
$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:
@ -53,6 +53,7 @@ location:
$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
link: ${business.location.link}
apartments:
title:
@ -68,16 +69,14 @@ apartments:
- /apartments/2/8.webp
- /apartments/2/7.webp
features:
bruendl:
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.
$de: Unser Geheimtipp
$en: Our insider tip
description:
$de: Reservieren Sie Ihr Equipment beim Sportgeschäft in nächster Nähe und sichern Sie sich dabei exklusive Rabatte.
$en: Make a reservation for your equipment at the sports shop nearby and enjoy exclusive discounts.
banner:
$de: /bruendl/de-mobile.jpg
$en: /bruendl/en-mobile.jpg
link: ${bruendllink}

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
useSeoLinking()
const { l } = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
useHead({
script: [
{
src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/jquery.js',
defer: true,
},
{
src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/capcorn.js',
defer: true,
},
],
link: [
{
rel: 'stylesheet',
href: 'https://mainframe.capcorn.net/ressourcen/newUI/css/capcorn.css',
},
],
})
// LG=0 für deutsch, LG=1 für englisch
// MP = maximale personenzahl
// maxZim
</script>
<template>
<XScaffoldGridPage>
<AppSection mode="card">
<ClientOnly>
<iframe
id="iframeCapCorn"
src="https://www.capcorn.net/Query?MB=1487&FL=17&LG=0&maxZim=3&zimDe=Apartment&zimEn=Apartment&showVpf=0"
frameborder="0"
width="100%"
scrolling="auto"
>
</iframe>
</ClientOnly>
</AppSection>
</XScaffoldGridPage>
</template>

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
useSeoLinking()
const { l } = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
useHead({
script: [
{
src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/jquery.js',
defer: true,
},
{
src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/capcorn.js',
defer: true,
},
],
link: [
{
rel: 'stylesheet',
href: 'https://mainframe.capcorn.net/ressourcen/newUI/css/capcorn.css',
},
],
})
</script>
<template>
<XScaffoldGridPage>
<AppSection mode="flat">
<XContainerPair break="2xl">
<template #left>
<div class="flex flex-col">
<XText as="section" :text="l.title" />
<div class="flex flex-col fgap-2 fmb-2">
<XText as="paragraph" :text="l.description" :margin="false" />
<XContactDetailGroup :names="['phone', 'email']" />
<XText as="paragraph" :margin="false">
{{ l.online1 }}
<AppButton type="here" variant="outline" />
{{ l.online2 }}
</XText>
</div>
</div>
</template>
<template #right>
<div id="hosts" class="w-full h-full flex">
<div class="flex flex-col">
<XText as="section">{{ l.heroes.title }}</XText>
<div class="flex flex-col rgap-2">
<div class="flex flex-row items-center rgap-1.5">
<NuxtImg
class="self-center object-cover rounded-full w-30"
:src="l.heroes.parents.image"
/>
<div>
<XText class="text-left" as="label" :text="l.heroes.parents.title" />
<XText
class="text-left"
as="paragraph"
:text="l.heroes.parents.description"
:margin="false"
/>
</div>
</div>
<div class="flex flex-row items-center rgap-1.5">
<div>
<XText class="text-right" as="label" :text="l.heroes.children.title" />
<XText
class="text-right"
as="paragraph"
:text="l.heroes.children.description"
:margin="false"
/>
</div>
<NuxtImg
class="self-center object-cover rounded-full w-30"
:src="l.heroes.children.image"
/>
</div>
</div>
</div>
</div>
</template>
</XContainerPair>
</AppSection>
<AppSection mode="card">
<ClientOnly>
<iframe
id="iframeCapCorn"
src="https://www.capcorn.net/MasterReq?FT=9&MB=1487&FL=17&LG=0"
frameborder="0"
width="100%"
scrolling="auto"
></iframe>
</ClientOnly>
</AppSection>
</XScaffoldGridPage>
</template>

View File

@ -16,58 +16,12 @@ 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:
title:
$de: Ihre Gastgeber
$en: Your Hosts
parents:
title: Monika & Norbert
description:

View File

@ -1,21 +1,30 @@
<template>
<div>
<AppFlatSection>
<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">
<article
<script setup lang="ts">
useSeoLinking()
const { l } = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
definePageMeta({
scaffoldHeaderHeroMainFooter: {
hero: false,
},
})
</script>
class="mb-12"
>
<template>
<XScaffoldGridPage>
<AppSection mode="flat">
<section
v-for="block in l.blocks"
:key="block.anchor"
:id="block.anchor"
class="prose mx-auto scroll-mt-[50rem]"
>
<article class="mb-12">
<h1 class="text-3xl font-bold mb-6 whitespace-pre-line">{{ block.title }}</h1>
<section
v-for="s in block.sections"
:key="s.title"
class="mb-8 whitespace-pre-line"
>
<section v-for="s in block.sections" :key="s.title" class="mb-8 whitespace-pre-line">
<h2 class="text-2xl font-semibold mb-4">{{ s.title }}</h2>
<p v-for="p in s.paragraphs" :key="p" class="mb-3 leading-relaxed whitespace-pre-line">
@ -24,15 +33,6 @@
</section>
</article>
</section>
</AppFlatSection>
</div>
</AppSection>
</XScaffoldGridPage>
</template>
<script setup lang="ts">
useSeoLinking()
const {l} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -18,12 +18,12 @@ blocks:
$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}
Anschrift: ${business.location.address}
Telefon: ${business.phone}
Email: ${business.email}
$en: |-
Operator: ${business.operator}
Address: ${business.address}
Address: ${business.location.address}
Phone: ${business.phone}
Email: ${business.email}
- $de: |-

View File

@ -1,61 +0,0 @@
<template>
<section class="w-full">
<!-- two-column card ----------------------------->
<div class="flex flex-col md:flex-row gap-4">
<!-- left half : image carousel -->
<AppThumbnailCarousel :images="apartment.images" class="basis-1/3"></AppThumbnailCarousel>
<!-- right half : text (top) & icons (bottom) -->
<div class="basis-2/3 flex flex-col h-full p-4 gap-4">
<!-- title + intro copy -->
<div class="flex-1 flex flex-col gap-4">
<!-- title -->
<h2 class="text-2xl font-bold">
{{ apartment.title }}
</h2>
<!-- description (optional) -->
<p
v-if="true"
class="text-lg pb-4">
{{
apartment.subtitle
}}
</p>
<!-- anything the parent puts in here -->
<AppFeaturesGrid :features="apartment.features"/>
</div>
<div class="flex-1"></div>
<div class="flex justify-end">
<div class="flex flex-row gap-4 ">
<UButton
:to="p('contact')"
size="md"
color="primary"
variant="outline"
trailing-icon="i-heroicons-envelope"
>
{{ g.button.contact }}
</UButton>
<UButton
:to="p('book')"
size="md"
color="primary"
variant="solid"
trailing-icon="i-heroicons-calendar-days"
>
{{ g.button.book }}
</UButton>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const {g, p} = useContentInjected()
defineProps<{ apartment: Apartment, index: number }>()
</script>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
const props = defineProps({
id: {type: String, default: undefined},
padTop: {type: Boolean, default: true},
padBottom: {type: Boolean, default: true}
})
</script>
<template>
<!-- bind the id renders only when props.id is truthy -->
<section :id="props.id" class="w-full">
<AppStripe>
<div :class="[
props.padTop && 'pt-12',
props.padBottom && 'pb-12'
]">
<div class="bg-neutral-100 border border-neutral-200">
<slot/>
</div>
</div>
</AppStripe>
</section>
</template>

View File

@ -1,40 +0,0 @@
<template>
<div
class="grid gap-y-8 gap-x-10
grid-cols-[repeat(auto-fit,minmax(12rem,1fr))]">
<div
v-for="(feat, idx) in features"
:key="idx"
class="flex items-start gap-4">
<UIcon
:name="`i-lucide-${feat.icon}`"
class="w-6 h-6 shrink-0 text-primary-600 mt-0.5"
/>
<div>
<h3 class="font-semibold text-neutral-700 leading-tight">
{{ feat.label }}
</h3>
<p
v-if="feat.detail"
class="text-sm text-neutral-600">
{{ feat.detail }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Feature {
icon: string
label: string
detail?: string
}
const props = defineProps<{
features: Feature[]
}>()
</script>

View File

@ -1,11 +0,0 @@
<template>
<section class="w-full bg-neutral-100 border-t border-b border-neutral-200">
<AppStripe>
<div class="py-12">
<slot/>
</div>
</AppStripe>
</section>
</template>
<script setup lang="ts">
</script>

View File

@ -1,78 +0,0 @@
<template>
<footer class="bg-neutral-100 border-t border-neutral-200">
<AppStripe>
<!-- 📌 contact block -->
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between
gap-4 py-4 border-b border-neutral-300"
>
<!-- avatar + copy -->
<AppHero :src="l.image"
alt="Ihre Gastgeberin Monika"
:size="16"
:title="l.questions"
:description="l.prompt">
<!-- Contact shortcuts -->
<div class="mt-2 space-y-1">
<!-- Phone (phone + WhatsApp icons) -->
<div class="flex items-center gap-2">
<!-- Heroicons phone -->
<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:${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:${g.business.email}`" class="hover:underline text-sm text-neutral-600">
{{ g.business.email }}
</a>
</div>
</div>
</AppHero>
<div class="flex flex-col gap-4 ">
<UButton
:to="p('contact')"
size="md"
color="primary"
variant="solid"
trailing-icon="i-heroicons-envelope"
>
{{ g.button.contact }}
</UButton>
<UButton
:to="p('book')"
size="md"
color="primary"
variant="solid"
trailing-icon="i-heroicons-calendar-days"
>
{{ g.button.book }}
</UButton>
</div>
</div>
<!-- © line -->
<div class="pt-4 text-center text-sm text-neutral-600 flex flex-col py-4">
<span>&copy; {{ year }} {{ g.business.name }}</span>
<div>
<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>
</footer>
</template>
<script setup lang="ts">
const {g, l, p} = useContentInjected()
const year = new Date().getFullYear()
</script>

View File

@ -1,16 +0,0 @@
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

@ -1,50 +0,0 @@
<template>
<header class="sticky top-0 z-50 bg-neutral-200 shadow text-neutral-700">
<AppStripe>
<nav class="mx-auto py-4 flex items-center justify-between">
<!-- your logo / home link -->
<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">
{{ l.home }}
</span>
</NuxtLink>
<!-- nav links -->
<ul class="flex space-x-6">
<li>
<!-- Mobile: Show only first word -->
<NuxtLink
:to="p('apartments')"
class="block sm:hidden"
>
{{ l.apartments.split(' ')[0] }}
</NuxtLink>
<!-- Desktop: Show full text -->
<NuxtLink
:to="p('apartments')"
class="hidden sm:block"
>
{{ l.apartments }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="p('book')">{{ l.book }}</NuxtLink>
</li>
<li>
<NuxtLink :to="p('contact')">{{ l.contact }}</NuxtLink>
</li>
<LocaleSwitcher/>
<VariantSwitcher/>
</ul>
</nav>
</AppStripe>
</header>
</template>
<script setup lang="ts">
const {l, p} = useContentInjected()
</script>

View File

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

View File

@ -1,55 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue'
const props = defineProps({
/* Path or URL for the portrait image */
src: {type: String, required: true},
/* Alt text for the image */
alt: {type: String, default: ''},
/**
* Tailwind size token (e.g. 14 w-14 h-14 or "full" w-full h-full).
* Defaults to 14 (= 3.5 rem = 56 px).
*/
size: {type: [Number, String], default: 14},
/* Heading / description */
title: {type: String, required: true},
description: {type: String, required: true},
/* Image left or right */
imageSide: {type: String as () => 'left' | 'right', default: 'left'},
/* Extra wrapper classes */
wrapperClass: {type: String, default: ''}
})
/* Tailwind width/height classes */
const sizeClasses = computed(() => `w-${props.size} h-${props.size}`)
/* If the token is numeric, convert to px for NuxtImg width/height props */
const numericSize = computed(() => {
const n = Number(props.size)
return Number.isFinite(n) ? n * 4 /* Tailwind token ×4 px */ : undefined
})
const wrapperFlex = computed(() =>
props.imageSide === 'right' ? 'flex-row-reverse text-right' : 'flex-row text-left'
)
</script>
<template>
<div :class="['flex items-center gap-4', wrapperFlex, wrapperClass]">
<NuxtImg
:src="src"
:alt="alt"
:width="numericSize"
:height="numericSize"
class="rounded-full object-cover shrink-0"
:class="sizeClasses"
/>
<div>
<p class="font-semibold text-neutral-700">{{ title }}</p>
<p class="text-sm text-neutral-600">{{ description }}</p>
<slot/>
</div>
</div>
</template>

View File

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

View File

@ -1,96 +0,0 @@
- 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,12 +0,0 @@
<script setup lang="ts">
defineOptions({inheritAttrs: false})
const {innerClass = 'px-4 sm:px-6 lg:px-8'} = defineProps<{ innerClass?: string }>()
</script>
<template>
<div class="w-full flex justify-center">
<div :class="['w-full max-w-6xl', innerClass]" v-bind="$attrs">
<slot/>
</div>
</div>
</template>

View File

@ -1,98 +0,0 @@
<script setup lang="ts">
// import {ref} from 'vue'
// const {t, tm, rt} = useVariantData()
// import {useTemplateRef} from '#imports'
//
interface Props {
images?: string[] // Fragezeichen = optional
}
//
const props = withDefaults(defineProps<Props>(), {
images: () => [
'https://picsum.photos/200/300'
]
})
/* ───────── shared slider state ───────── */
const carousel = useTemplateRef('carousel')
const activeIndex = ref(0)
function onSelect(index: number) {
activeIndex.value = index
}
function select(index: number) {
activeIndex.value = index
carousel.value?.emblaApi?.scrollTo(index)
}
// Weird stuff is going on here.
// - If the page loads slowly, some images are not shown in the carousel viewport
// - However they are visible when dragging left or right
// - When removing overflow-hidden from the direct parent of the images row all are visible
// - Potentially only happens when auto-height plugin is used
// - When resizing the window suddenly all images become visible
// - Updating the key to force to re-render is just a hack
const version = ref(0)
const loaded = new Set<string>()
function updateVersion(img) {
if (!loaded.has(img)) {
loaded.add(img)
++version.value
console.log("update version", version.value)
}
}
// const loaded = Object.fromEntries(props.images.map(img => [img, ref(img)]))
//
// function setLoaded(item) {
// loaded[item].value = item + 'loaded'
// }
</script>
<template>
<div class="flex flex-col items-center justify-start p-4">
<!-- main slider -->
<UCarousel
:key="version"
ref="carousel"
auto-height
arrows
prev-icon="i-lucide-chevron-left"
next-icon="i-lucide-chevron-right"
v-slot="{ item }"
:items="props.images"
:autoplay="{ delay: 5_000 }"
:ui="{item: 'ps-0',
controls: 'absolute top-6 inset-x-15',
dots: '-top-7',
dot: 'w-1 h-1'}"
loop
class="relative w-full"
@select="onSelect"
>
<NuxtImg :src="item" class="object-cover" alt="Bild" preload @load="updateVersion(item)"/>
</UCarousel>
<!-- thumbnails -->
<!-- <div class="flex flex-wrap gap-2 pt-4">-->
<!-- <button-->
<!-- v-for="(thumb, idx) in props.images"-->
<!-- :key="idx"-->
<!-- @click="select(idx)"-->
<!-- :class="[-->
<!-- 'size-16 rounded-lg overflow-hidden opacity-40 hover:opacity-100',-->
<!-- 'transition-opacity focus:outline-none focus-visible:ring',-->
<!-- activeIndex === idx && 'opacity-100 ring-2 ring-primary-500'-->
<!-- ]"-->
<!-- >-->
<!-- <img :src="thumb" alt="" class="w-full h-full object-cover"/>-->
<!-- </button>-->
<!-- </div>-->
</div>
</template>

View File

@ -1,27 +0,0 @@
<!-- components/TransBlock.vue -->
<template>
<div class="flex-1">
<!-- title -->
<h2 class="text-2xl font-bold mb-4">
{{ title }}
</h2>
<!-- description (optional) -->
<p
v-if="text"
class="text-lg pb-4">
{{ text }}
</p>
<!-- anything the parent puts in here -->
<slot/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string,
text?: string
}>()
</script>

View File

@ -1,30 +0,0 @@
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

@ -1,16 +0,0 @@
<template>
<UApp :toaster="{ position: 'top-center' }">
<div class="flex flex-col min-h-dvh bg-neutral-50">
<AppHeader/>
<main class="flex-1">
<slot/>
</main>
<AppFooter/>
</div>
</UApp>
</template>
<script setup lang="ts">
</script>

View File

@ -1,20 +1,17 @@
import tailwindcss from '@tailwindcss/vite'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
extends: [ '@layers/content' ],
modules: [
"@nuxt/ui",
"@nuxt/image",
"@nuxt/fonts",
"@nuxt/icon"
],
css: [ '~/assets/css/main.css' ],
extends: ['@layers/ux'],
modules: ['@nuxt/ui', '@nuxt/image', '@nuxt/fonts', '@nuxt/icon'],
css: ['./app/assets/css/main.css'],
ui: {
colorMode: false
colorMode: false,
},
app: {
pageTransition: { name: 'page', mode: 'out-in' }
pageTransition: { name: 'page', mode: 'out-in' },
},
fonts: {
providers: {
@ -23,12 +20,12 @@ export default defineNuxtConfig({
// bunny: false,
fontshare: false,
fontsource: false,
adobe: false
}
adobe: false,
},
},
icon: {
provider: 'none',
fallbackToApi: false,
fallbackToApi: false, // TODO
clientBundle: {
scan: true,
includeCustomCollections: true,
@ -61,8 +58,27 @@ export default defineNuxtConfig({
'lucide:coffee',
'lucide:layers',
'lucide:wind',
'lucide:sun'
]
}
}
})
'lucide:sun',
'heroicons:home',
'heroicons:bars-3',
'heroicons:x-mark',
'heroicons:map-pin',
'heroicons:envelope',
'heroicons:phone',
'lucide:arrow-right',
'lucide:arrow-left',
'lucide:loader-circle',
'heroicons:paper-airplane',
],
// clientBundle: { // TODO check this out sometime
// scan: {
// globInclude: ['components/**/*.vue', /* ... */],
// globExclude: ['node_modules', 'dist', /* ... */],
// },
// },
},
},
// vite: {
// plugins: [tailwindcss()],
// },
})

View File

@ -1,30 +1,37 @@
{
"name": "panoramablick-saalbach.at",
"name": "@apps/panoramablick-saalbach.at",
"type": "module",
"version:": "0.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"manage": "pnpm -w manage"
},
"prettier": "@webapps/code/prettier",
"dependencies": {
"@layers/content": "workspace:0.0.0",
"@nuxt/fonts": "^0.11.4",
"@nuxt/icon": "^2.0.0",
"@layers/ux": "workspace:*",
"@nuxt/fonts": "0.11.4",
"@nuxt/icon": "2.1.0",
"@nuxt/image": "1.11.0",
"@nuxt/kit": "^4.1.3",
"@nuxt/ui": "^4.0.1",
"nuxt": "^4.1.2",
"tailwindcss": "^4.1.14",
"valibot": "^1.1.0",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
"@nuxt/kit": "4.2.0",
"@nuxt/ui": "4.1.0",
"@tailwindcss/vite": "4.1.16",
"nitropack": "2.12.8",
"nuxt": "4.2.0",
"tailwindcss": "4.1.16",
"valibot": "1.1.0",
"vite": "7.1.12",
"vue": "3.5.22",
"vue-router": "4.6.3"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.2.3",
"@iconify-json/lucide": "^1.2.70",
"@iconify-json/uil": "^1.2.3"
"@iconify-json/heroicons": "1.2.3",
"@iconify-json/lucide": "1.2.71",
"@iconify-json/uil": "1.2.3",
"@webapps/code": "workspace:*",
"prettier": "3.6.2"
}
}

View File

@ -1,36 +0,0 @@
<template>
<div class="w-full h-full bg-neutral-50">
<AppFlatSection>
<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="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="l.highlight.title" :text="l.highlight.description">
<AppHighlights/>
</AppTitleText>
</div>
</AppFlatSection>
<AppCardSection
v-for="(apartment, index) in l.list"
:key="index"
:padTop="index === 0"
>
<AppApartment :apartment="apartment" :index="index" :id="g.apartments[index].id"/>
</AppCardSection>
</div>
</template>
<script setup lang="ts">
useSeoLinking()
const {g, l} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -1,170 +0,0 @@
<template>
<div>
<div class="relative w-full">
<div class="absolute inset-0 z-0">
<UCarousel
v-slot="{ item }"
:items="l.welcome.images"
:ui="{item: 'basis-full h-full ps-0', container: 'flex items-stretch h-full'}"
:autoplay="{ delay: 5000 }"
:loop="true"
:fade="true"
:duration="150"
class="w-full h-full custom-carousel">
<div class="w-full h-full flex items-center justify-center">
<NuxtImg :src="item" class="w-full h-full object-cover" alt="Demo Picture"/>
</div>
</UCarousel>
</div>
<div class="absolute inset-0 z-5 bg-black/60"/>
<div class="relative z-10">
<AppStripe class="text-white py-4 sm:py-8 lg:py-16">
<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">{{ l.welcome.title }}</h1>
<p class="mt-4 text-lg">
{{
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">{{ g.button.apartments }}
</UButton>
<UButton :to="p('book')" color="secondary" variant="solid" size="xl"
trailing-icon="i-heroicons-calendar-days">
{{ g.button.book }}
</UButton>
<UButton :to="p('contact')" color="secondary" variant="solid" size="xl"
trailing-icon="i-heroicons-envelope">
{{ g.button.contact }}
</UButton>
</div>
</div>
<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"
alt="Joker Card"
class="w-30 md:w-50 transform rotate-15"
style="border-radius: .5rem"
/>
</a>
</div>
</div>
</AppStripe>
</div>
</div>
<AppFlatSection>
<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="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="l.highlight.title" :text="l.highlight.description">
<UButton :to="p('contact#hosts')" color="primary" variant="solid" size="xl"
trailing-icon="i-heroicons-arrow-right">
{{
g.button.hosts
}}
</UButton>
</AppTitleText>
</div>
</AppFlatSection>
<AppCardSection>
<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="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">
{{ g.button.map }}
</UButton>
</AppTitleText>
</div>
<!-- Right: 60% width on desktop, full on mobile -->
<a
href="https://maps.app.goo.gl/FtTC8ervoBCnfU3j7"
class="w-full md:w-[60%] overflow-hidden relative group"
aria-label="Google Maps öffnen"
>
<NuxtImg
: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"
/>
</a>
</div>
</AppCardSection>
<AppFlatSection>
<AppTitleText :title="l.apartments.title" :text="l.apartments.description">
<div class="flex flex-wrap gap-4 justify-center">
<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="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">
<ul>
<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>
</NuxtLink>
</div>
</AppTitleText>
</AppFlatSection>
<AppCardSection>
<div class="flex-1 pr">
<h2 class="text-2xl font-bold mb-4">
{{ l.features.title }}
</h2>
<p class="text-lg pb-4">
{{ l.features.description1 }}
<UButton to="https://www.bruendl.at/" variant="outline" trailing-icon="i-heroicons-arrow-right">{{
l.features.description2
}}
</UButton>
{{ l.features.description3 }}
</p>
<AppHighlights/>
</div>
</AppCardSection>
</div>
</template>
<style scoped>
/* TODO this is somewhat hacky, but currently no other way to style carousel inner wrapper */
:deep(.custom-carousel > .overflow-hidden) {
height: 100%;
}
</style>
<script setup lang="ts">
useSeoLinking()
const {g, l, p} = useContentInjected()
useSeoMeta({
title: () => l.value.meta.title,
description: () => l.value.meta.description,
})
</script>

View File

@ -1,55 +0,0 @@
<!--<script setup lang="ts">-->
<!--/* Inject the external assets into <head> */-->
<!--useHead({-->
<!-- link: [-->
<!-- {-->
<!-- rel: 'stylesheet',-->
<!-- href: 'https://mainframe.capcorn.net/ressourcen/newUI/css/capcorn.css'-->
<!-- }-->
<!-- ],-->
<!-- script: [-->
<!-- {-->
<!-- src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/jquery.js',-->
<!-- tagPosition: 'bodyClose', // load at the end of <body>-->
<!-- defer: true // optional-->
<!-- },-->
<!-- {-->
<!-- src: 'https://mainframe.capcorn.net/ressourcen/newUI/js/capcorn.js',-->
<!-- tagPosition: 'bodyClose',-->
<!-- defer: true-->
<!-- }-->
<!-- ]-->
<!--})-->
<!--</script>-->
<template>
<div>
<AppFlatSection>
<div class="flex flex-col items-center justify-center">
<span class="text-lg text-neutral-600">{{l.coming}}</span>
</div>
<!-- &lt;!&ndash; SSR-safe: iframe appears only in the browser &ndash;&gt;-->
<!-- &lt;!&ndash; <ClientOnly>-->
<!-- <iframe-->
<!-- id="iframeCapCorn"-->
<!-- src="https://www.capcorn.net/MasterReq?MB=1487&FL=17&LG=0"-->
<!-- frameborder="0"-->
<!-- width="100%"-->
<!-- scrolling="auto"-->
<!-- />-->
<!-- </ClientOnly> &ndash;&gt;-->
</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

@ -1,161 +0,0 @@
<template>
<div>
<!-- Contactcentric layout -->
<AppFlatSection>
<!-- Grid: form (fixed maxwidth) | host snapshots -->
<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">{{ l.title }}</h1>
<!-- Extended intro -->
<p class="text-neutral-600 mb-4 max-w-prose">
{{ l.description }}
</p>
<!-- Contact shortcuts -->
<div class="mb-8 space-y-1 text-sm">
<!-- Phone (phone + WhatsApp icons) -->
<div class="flex items-center gap-2">
<!-- Heroicons phone -->
<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:${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:${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">
{{ l.online1 }}
<UButton :to="p('book')" variant="outline" trailing-icon="i-heroicons-calendar-days">{{
l.online2
}}
</UButton>
{{ 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="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="l.form.email.prompt"/>
</UFormField>
<UFormField name="subject" label="Betreff" :ui="{ label: 'sr-only' }">
<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="l.form.message.prompt"/>
</UFormField>
<UButton type="submit" color="primary" size="lg" class="w-full sm:w-auto" trailing-icon="i-heroicons-paper-airplane">
{{ l.form.send }}
</UButton>
</UForm>
</div>
<!-- Decorative host snapshots -->
<div id="hosts" class="flex flex-col gap-10 lg:self-center w-full md:w-auto">
<AppHero
:src="l.heroes.parents.image"
:alt="l.heroes.parents.title"
image-side="left"
:size="50"
:title="l.heroes.parents.title"
:description="l.heroes.parents.description"
/>
<AppHero
:src="l.heroes.children.image"
:alt="l.heroes.children.title"
image-side="right"
:size="50"
:title="l.heroes.children.title"
:description="l.heroes.children.description"
/>
</div>
</div>
</AppFlatSection>
</div>
</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?*/
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, 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>
/* ───── reactive form state ───── */
const state = reactive({
name: '', email: '', subject: '', message: ''
})
const toast = useToast()
/* ───── submit handler ───── */
async function onSubmit(event: FormSubmitEvent<Schema>) {
const hotelId = g.value.business.uid ?? ''
try {
await $fetch('https://api.dominikmilacher.com/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: {
...event.data,
hotel: hotelId
}
})
toast.add({
title: l.value.form.sent.title,
description: l.value.form.sent.description,
color: 'primary'
})
/* reset fields */
Object.assign(state, {name: '', email: '', subject: '', message: ''})
} catch (err: any) {
//console.log(err?.data?.detail)
//console.log(err?.message)
toast.add({
title: l.value.form.error.title,
description: l.value.form.error.description,
color: 'primary'
})
}
}
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

View File

@ -6,15 +6,52 @@
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.5.8",
"typescript": "5.9.2"
"check-types": "turbo run check-types",
"check-deps": "syncpack list-mismatches",
"fix-deps": "syncpack fix-mismatches",
"bootstrap": "pnpm -w node scripts/bootstrap.mjs",
"manage": "pnpm -w node scripts/manage.mjs"
},
"prettier": "@webapps/code/prettier",
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
"node": "22"
},
"devDependencies": {
"@inquirer/prompts": "7.9.0",
"chalk": "5.6.2"
},
"pnpm": {
"overrides": {
"@inquirer/prompts": "7.9.0",
"chalk": "5.6.2",
"nuxt": "4.2.0",
"@nuxt/kit": "4.2.0",
"vue": "3.5.22",
"prettier": "3.6.2",
"eslint-config-prettier": "10.1.8",
"@webapps/code": "workspace:*",
"@types/node": "24.9.1",
"@nuxt/eslint": "1.9.0",
"esbuild": "0.25.11",
"yaml": "2.8.1",
"@iconify-json/heroicons": "1.2.3",
"@iconify-json/lucide": "1.2.71",
"@iconify-json/uil": "1.2.3",
"vue-router": "4.6.3",
"valibot": "1.1.0",
"@nuxt/ui": "4.1.0",
"@nuxt/fonts": "0.11.4",
"@nuxt/image": "1.11.0",
"@nuxt/icon": "2.1.0",
"@layers/content": "workspace:*",
"vite": "7.1.12",
"nitropack": "2.12.8",
"tailwindcss": "4.1.16",
"@tailwindcss/vite": "4.1.16",
"tailwind-variants": "3.1.1",
"tailwind-merge": "3.3.1",
"@layers/ux": "workspace:*"
}
}
}
}

7
packages/code/eslint.mjs Normal file
View File

@ -0,0 +1,7 @@
export default [
{
rules: {
'import/no-unresolved': ['error', { ignore: ['^virtual:'] }],
},
},
]

View File

@ -0,0 +1,11 @@
{
"name": "@webapps/code",
"version": "0.0.0",
"type": "module",
"private": true,
"prettier": "@webapps/code/prettier",
"exports": {
"./eslint": "./eslint.mjs",
"./prettier": "./prettier.mjs"
}
}

View File

@ -0,0 +1,7 @@
export default {
semi: false,
singleQuote: true,
trailingComma: 'es5',
printWidth: 100,
tabWidth: 2,
}

View File

@ -1,3 +0,0 @@
# `@turbo/eslint-config`
Collection of internal eslint configurations.

View File

@ -1,32 +0,0 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import turboPlugin from "eslint-plugin-turbo";
import tseslint from "typescript-eslint";
import onlyWarn from "eslint-plugin-only-warn";
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
plugins: {
turbo: turboPlugin,
},
rules: {
"turbo/no-undeclared-env-vars": "warn",
},
},
{
plugins: {
onlyWarn,
},
},
{
ignores: ["dist/**"],
},
];

View File

@ -1,49 +0,0 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import pluginNext from "@next/eslint-plugin-next";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use Next.js.
*
* @type {import("eslint").Linter.Config[]}
* */
export const nextJsConfig = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
"@next/next": pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules,
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

View File

@ -1,24 +0,0 @@
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@next/eslint-plugin-next": "^15.5.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.5.0",
"globals": "^16.3.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
}
}

View File

@ -1,39 +0,0 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import { config as baseConfig } from "./base.js";
/**
* A custom ESLint configuration for libraries that use React.
*
* @type {import("eslint").Linter.Config[]} */
export const config = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
...globals.browser,
},
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
},
settings: { react: { version: "detect" } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
},
},
];

View File

@ -1,12 +0,0 @@
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@ -1,2 +0,0 @@
shamefully-hoist=true
strict-peer-dependencies=false

View File

@ -1 +0,0 @@
typescript.includeWorkspace = true

View File

@ -1,5 +0,0 @@
export default defineAppConfig({
myLayer: {
name: 'My amazing Nuxt layer (overwritten)'
}
})

View File

@ -1,3 +1,3 @@
<template>
<NuxtPage/>
<NuxtPage />
</template>

View File

@ -0,0 +1,2 @@
content:
kik: kokk

View File

@ -0,0 +1,5 @@
<script setup>
const { g, l } = useContentInjected()
</script>
<template>{{ g }} {{ l }}</template>

View File

@ -0,0 +1 @@
hey: thereee

View File

@ -1,7 +0,0 @@
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

@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View File

@ -5,8 +5,7 @@ export default defineNuxtConfig({
modules: ['@nuxt/eslint'],
eslint: {
config: {
// Use the generated ESLint config for lint root project as well
rootDir: fileURLToPath(new URL('..', import.meta.url))
}
}
rootDir: fileURLToPath(new URL('..', import.meta.url)),
},
},
})

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
const { locale, locales, variant, variants, c } = useContent()
</script>
<template>
<p>locale {{ locale }}</p>
<p>locales {{ locales }}</p>
<p>variant {{ variant }}</p>
<p>variants {{ variants }}</p>
<LocaleSwitcher/>
<br/>
<VariantSwitcher/>
<p>{{ c.cookies.title }}</p>
</template>

View File

@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": ".nuxt/tsconfig.app.json"
},
{
"path": ".nuxt/tsconfig.server.json"
},
{
"path": ".nuxt/tsconfig.shared.json"
},
{
"path": ".nuxt/tsconfig.node.json"
}
]
}

View File

@ -1,73 +0,0 @@
# Nuxt Layer Starter
Create Nuxt extendable layer with this GitHub template.
## Setup
Make sure to install the dependencies:
```bash
pnpm install
```
## Working on your layer
Your layer is at the root of this repository, it is exactly like a regular Nuxt project, except you can publish it on NPM.
The `.playground` directory should help you on trying your layer during development.
Running `pnpm dev` will prepare and boot `.playground` directory, which imports your layer itself.
## Distributing your layer
Your Nuxt layer is shaped exactly the same as any other Nuxt project, except you can publish it on NPM.
To do so, you only have to check if `files` in `package.json` are valid, then run:
```bash
npm publish --access public
```
Once done, your users will only have to run:
```bash
npm install --save your-layer
```
Then add the dependency to their `extends` in `nuxt.config`:
```ts
defineNuxtConfig({
extends: 'your-layer'
})
```
## Development Server
Start the development server on http://localhost:3000
```bash
pnpm dev
```
## Production
Build the application for production:
```bash
pnpm build
```
Or statically generate it with:
```bash
pnpm generate
```
Locally preview production build:
```bash
pnpm preview
```
Checkout the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@ -0,0 +1 @@
adding a page in the playground pages/[locale]/[variant] prevents the panoramablick app to start in dev (eslint)?!

View File

@ -0,0 +1 @@
export default defineAppConfig({})

View File

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

View File

@ -1,16 +1,18 @@
import {type JsonValue} from '../utils/content-canonical'
import {buildContent, expandContent} from '../utils/content-reduced'
import {useContentPreference} from "./useContentPreference"
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')
throw new Error(
'useContentInjected called without arguments, there might be a problem with the Vite plugin'
)
}
const route = useRoute()
const {preferredLocale, preferredVariant} = useContentPreference()
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)
@ -18,14 +20,15 @@ export function useContentInjected(slug?: string, global?: JsonValue, local?: Js
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}) {
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
function replace(what: string, value: string) {
// replaces only the first occurrence
const expression = new RegExp(`/${what}(?=[/?#]|$)`)
return result.replace(expression, `/${value}`)
}
@ -57,18 +60,24 @@ export function useContentInjected(slug?: string, global?: JsonValue, local?: Js
return {
locale: lo,
variant: va,
global: g,
local: l,
g: g,
l: l,
global: g as ComputedRef,
local: l as ComputedRef,
g: g as ComputedRef,
l: l as ComputedRef,
path: path,
p: path
p: path,
}
}
const cache = new Map<string, JsonValue | undefined>()
function getContent(slug: string, locale?: string, variant?: string, global?: JsonValue, local?: JsonValue): 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)
@ -76,13 +85,15 @@ function getContent(slug: string, locale?: string, variant?: string, global?: Js
return hit
}
const locales: string[] = config.locale?.list.map(l => l.code) ?? []
const variants: string[] = config.variant?.list.map(v => v.code) ?? []
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)
content === undefined
? [undefined, false]
: buildContent(locale, locales, variant, variants, content)
if (reduced && expand) {
if (slug === 'content.global') {

View File

@ -1,18 +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 locales = config.locale?.list.map(l => l.code) ?? []
const variants = config.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.locale?.cookieMaxAge ?? 0
maxAge: config.locale?.cookieMaxAge ?? 0,
})
const variantCookie = useCookie<string>('variant', {
sameSite: 'lax',
maxAge: config.variant?.cookieMaxAge ?? 0
maxAge: config.variant?.cookieMaxAge ?? 0,
})
function prefer(list: string[], cookie: CookieRef<string>) {
@ -23,7 +23,11 @@ export function useContentPreference() {
}
}
function preferred(list: string[], cookie: CookieRef<string | undefined>, fallback: string | undefined) {
function preferred(
list: string[],
cookie: CookieRef<string | undefined>,
fallback: string | undefined
) {
return computed(() => {
if (cookie.value !== undefined && list.includes(cookie.value)) {
return cookie.value
@ -39,6 +43,6 @@ export function useContentPreference() {
preferredLocale: preferred(locales, localeCookie, config.locale?.default),
preferLocale: prefer(locales, localeCookie),
preferredVariant: preferred(variants, variantCookie, config.variant?.default),
preferVariant: prefer(variants, variantCookie)
preferVariant: prefer(variants, variantCookie),
}
}

View File

@ -5,7 +5,7 @@ export default {
{ code: 'de', name: { de: 'Deutsch', en: 'German' }, icon: 'german' },
{ code: 'en', name: { de: 'Englisch', en: 'English' }, icon: 'english' },
],
cookieMaxAge: 60 * 60 * 24 * 365 * 3
cookieMaxAge: 60 * 60 * 24 * 365 * 3,
},
variant: {
default: 'winter',
@ -13,6 +13,6 @@ export default {
{ 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
}
cookieMaxAge: 60 * 60 * 24,
},
}

View File

@ -0,0 +1,2 @@
content:
hey: lologlob

View File

@ -0,0 +1,32 @@
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

@ -106,10 +106,17 @@ export class CanonicalPrimitive extends CanonicalBase {
}
override dropTags(except: string[]): CanonicalPrimitive {
return new CanonicalPrimitive(this.tags.filter(t => except.includes(t)), this.v, this.e)
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 {
override reduce(options: {
predicate: (tags?: string[]) => boolean
expand?: boolean
}): CanonicalPrimitive | Primitive | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
@ -123,7 +130,7 @@ export class CanonicalPrimitive extends CanonicalBase {
}
resolve(resolver: (key: string) => JsonValue): string {
return (this.v as string[]).map((p, i) => i % 2 ? resolver(p) : p).join('')
return (this.v as string[]).map((p, i) => (i % 2 ? resolver(p) : p)).join('')
}
}
@ -132,11 +139,7 @@ export class CanonicalObject extends CanonicalBase {
private n?: Record<string, CanonicalValue>
private a?: CanonicalValue[]
constructor(
tags: string[],
named: Record<string, CanonicalValue>,
anonymous: CanonicalValue[],
) {
constructor(tags: string[], named: Record<string, CanonicalValue>, anonymous: CanonicalValue[]) {
super(tags)
if (Object.keys(named).length) {
@ -191,7 +194,7 @@ export class CanonicalObject extends CanonicalBase {
}
if (json.a) {
object.a = (json.a as JsonValue[]).map(i => fromCanonicalJson(i))
object.a = (json.a as JsonValue[]).map((i) => fromCanonicalJson(i))
}
return object
@ -218,24 +221,37 @@ export class CanonicalObject extends CanonicalBase {
}
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))
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)
return new CanonicalObject(
this.tags.filter((t) => except.includes(t)),
n,
a
)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any | undefined {
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))
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)
const a = this.anonymous.map((a) => a.reduce(options)).filter((a) => a !== undefined)
if (a.length) {
if (a.length > 1) {
@ -268,8 +284,13 @@ export class CanonicalArray extends CanonicalBase {
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) {
json = json.filter((item) => {
if (
typeof item === 'object' &&
item !== null &&
Object.keys(item).length === 1 &&
'$tags' in item
) {
tags.push(...getInnerTags(item))
return false
}
@ -277,7 +298,10 @@ export class CanonicalArray extends CanonicalBase {
return true
})
return new CanonicalArray(tags, json.map(i => fromInputJson([], i)))
return new CanonicalArray(
tags,
json.map((i) => fromInputJson([], i))
)
}
static override fromCanonicalJson(json: JsonObject): CanonicalArray {
@ -285,28 +309,34 @@ export class CanonicalArray extends CanonicalBase {
Object.setPrototypeOf(array, CanonicalArray.prototype)
if (json.i) {
array.i = (json.i as JsonValue[]).map(i => fromCanonicalJson(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")
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)
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 {
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)
return this.items.map((i) => i.reduce(options)).filter((i) => i !== undefined)
}
}
@ -347,9 +377,11 @@ function mergeInto(self: CanonicalValue, other: CanonicalValue): CanonicalValue
}
}
if (self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive
|| self instanceof CanonicalObject && other instanceof CanonicalObject
|| self instanceof CanonicalArray && other instanceof CanonicalArray) {
if (
(self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive) ||
(self instanceof CanonicalObject && other instanceof CanonicalObject) ||
(self instanceof CanonicalArray && other instanceof CanonicalArray)
) {
return self.mergeInto(other)
}
@ -377,5 +409,8 @@ function getInnerTags(json: JsonObject): string[] {
}
function cleanTags(tags: string[]): string[] {
return [...new Set(tags)].map(t => t.trim()).filter(t => !!t).sort()
return [...new Set(tags)]
.map((t) => t.trim())
.filter((t) => !!t)
.sort()
}

View File

@ -0,0 +1,90 @@
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.app, `${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,4 +1,4 @@
import {type JsonValue, fromCanonicalJson, CanonicalPrimitive} from "./content-canonical";
import { type JsonValue, fromCanonicalJson, CanonicalPrimitive } from './content-canonical'
export function buildContent(
locale: string | undefined,
@ -8,7 +8,7 @@ export function buildContent(
content: JsonValue
): [JsonValue, boolean] {
function check(all: string[], specified: string[], query: string): boolean {
const related = specified.filter(t => all.includes(t))
const related = specified.filter((t) => all.includes(t))
all = related.length ? related : all
return all.includes(query)
}
@ -25,7 +25,7 @@ export function buildContent(
return !variant || check(variants, tags, variant)
}
const options = {predicate: predicate, expand: false}
const options = { predicate: predicate, expand: false }
const canonical = fromCanonicalJson(content)
const reduced = canonical.reduce(options)
@ -51,7 +51,7 @@ export function expandContent(content: JsonValue, sources: JsonValue[]): JsonVal
if (item instanceof CanonicalPrimitive) {
return item.resolve(resolve)
} else if (Array.isArray(item)) {
return item.map(i => traverse(i))
return item.map((i) => traverse(i))
} else if (typeof item === 'object') {
const record: Record<string, JsonValue> = {}
@ -81,41 +81,40 @@ export function expandContent(content: JsonValue, sources: JsonValue[]): JsonVal
// 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;
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]
tokens.push(Number(m[1])) // [123]
} else if (m[3] !== undefined) {
tokens.push(m[3]); // ["key"] / ['key']
tokens.push(m[3]) // ["key"] / ['key']
} else {
tokens.push(m[0]); // bare token
tokens.push(m[0]) // bare token
}
}
return tokens;
return tokens
}
function getPath(obj: unknown, path: string): JsonValue | undefined {
const steps = toPath(path);
let cur: any = obj;
const steps = toPath(path)
let cur: any = obj
for (const step of steps) {
if (cur == null) return undefined;
if (cur == null) return undefined
if (typeof step === 'number') {
if (!Array.isArray(cur) || step < 0 || step >= cur.length) return undefined;
cur = cur[step];
if (!Array.isArray(cur) || step < 0 || step >= cur.length) return undefined
cur = cur[step]
} else {
cur = cur[step];
cur = cur[step]
}
}
return cur as JsonValue;
return cur as JsonValue
}

View File

@ -1,12 +1,14 @@
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'
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))
const config = getLayerDirectories()
.map((d) => join(d.app, 'content.config.ts'))
.find((f) => existsSync(f))
if (config) {
const module = await import(config)
@ -20,18 +22,20 @@ export async function getRoutes(): Promise<Set<string>> {
async function find(directory: string): Promise<string[]> {
if (!existsSync(directory)) return []
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 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))))
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 shortcuts = [...routes].map((r) => [shorten(r), r] as [string, string])
const matches: Record<string, string[]> = {}
for (const [shortcut, route] of shortcuts) {
@ -52,19 +56,19 @@ export function getRouteShortcuts(routes: Set<string>): Record<string, string> {
}
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) ?? []
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])
combinations = variants.map((v) => [undefined, v])
} else if (!variants.length) {
combinations = locales.map(l => [l, undefined])
combinations = locales.map((l) => [l, undefined])
} else {
combinations = locales.flatMap(l => variants.map(v => [l, v] as [string, string]))
combinations = locales.flatMap((l) => variants.map((v) => [l, v] as [string, string]))
}
function expand(route: string, locale?: string, variant?: string): string {

View File

@ -0,0 +1,19 @@
declare module 'virtual:content/content.config' {
// Define the shape of your virtual module's exports here.
// For example, if it exports a default object:
const config: {
// Specify the properties and types of your config object
// e.g., pages: { path: string, title: string }[]
}
export default config
}
declare module 'virtual:content/shortcuts' {
// Define the shape of your virtual module's exports here.
// For example, if it exports a default object:
const config: {
// Specify the properties and types of your config object
// e.g., pages: { path: string, title: string }[]
}
export default config
}

View File

@ -1,7 +0,0 @@
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,18 +0,0 @@
<script setup lang="ts">
const config = useContentConfig()
const {preferLocale} = useContentPreference()
const {locale, path} = useContentInjected()
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.code"
:to="{ path: path(undefined, {locale: candidate.code}).value, query: { freeze: 'true' }}"
@click="preferLocale(candidate.code)">
{{ candidate.code.toUpperCase() }}
</NuxtLink>
</template>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
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>
<template>
<NuxtLink
v-for="candidate in candidates"
:key="candidate.code"
: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"/>
</NuxtLink>
</template>

Some files were not shown because too many files have changed in this diff Show More