Compare commits
2 Commits
e32a72868b
...
0dc24c4db7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dc24c4db7 | |||
| 7d73f2b784 |
4
.npmrc
4
.npmrc
@ -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
4
.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
.nuxt
|
||||
.output
|
||||
node_modules
|
||||
dist
|
||||
55
README.md
55
README.md
@ -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
|
||||
@ -1,9 +0,0 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'gimblet',
|
||||
secondary: 'stone',
|
||||
neutral: 'sandstone'
|
||||
}
|
||||
},
|
||||
})
|
||||
139
apps/panoramablick-saalbach.at/app/app.config.ts
Normal file
139
apps/panoramablick-saalbach.at/app/app.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -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>
|
||||
@ -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;*/
|
||||
}
|
||||
@ -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>
|
||||
20
apps/panoramablick-saalbach.at/app/components/AppButton.vue
Normal file
20
apps/panoramablick-saalbach.at/app/components/AppButton.vue
Normal 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>
|
||||
44
apps/panoramablick-saalbach.at/app/components/AppButton.yaml
Normal file
44
apps/panoramablick-saalbach.at/app/components/AppButton.yaml
Normal 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
|
||||
46
apps/panoramablick-saalbach.at/app/components/AppFooter.vue
Normal file
46
apps/panoramablick-saalbach.at/app/components/AppFooter.vue
Normal 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>
|
||||
@ -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!
|
||||
@ -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>
|
||||
104
apps/panoramablick-saalbach.at/app/components/AppHighlights.yaml
Normal file
104
apps/panoramablick-saalbach.at/app/components/AppHighlights.yaml
Normal file
@ -0,0 +1,104 @@
|
||||
title:
|
||||
$de: Darauf können Sie sich freuen!
|
||||
$en: That’s 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
|
||||
41
apps/panoramablick-saalbach.at/app/components/AppSection.vue
Normal file
41
apps/panoramablick-saalbach.at/app/components/AppSection.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -0,0 +1,2 @@
|
||||
left: /ap.webp
|
||||
right: /sauna.webp
|
||||
100
apps/panoramablick-saalbach.at/app/content.global.yaml
Normal file
100
apps/panoramablick-saalbach.at/app/content.global.yaml
Normal 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
|
||||
74
apps/panoramablick-saalbach.at/app/layouts/default.vue
Normal file
74
apps/panoramablick-saalbach.at/app/layouts/default.vue
Normal 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>
|
||||
@ -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',
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
@ -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>
|
||||
@ -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: Here’s 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
|
||||
@ -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>
|
||||
@ -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 you’ll 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 you’ll 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: That’s 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}
|
||||
49
apps/panoramablick-saalbach.at/app/pages/[locale]/book.vue
Normal file
49
apps/panoramablick-saalbach.at/app/pages/[locale]/book.vue
Normal 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>
|
||||
110
apps/panoramablick-saalbach.at/app/pages/[locale]/contact.vue
Normal file
110
apps/panoramablick-saalbach.at/app/pages/[locale]/contact.vue
Normal 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>
|
||||
@ -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:
|
||||
@ -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>
|
||||
@ -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: |-
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>© {{ 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>
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -1,10 +0,0 @@
|
||||
home: ${business.name}
|
||||
apartments:
|
||||
$de: Apartments & Preise
|
||||
$en: Apartments & Prices
|
||||
book:
|
||||
$de: Buchen
|
||||
$en: Book
|
||||
contact:
|
||||
$de: Kontakt
|
||||
$en: Contact
|
||||
@ -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>
|
||||
@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<AppFeaturesGrid :features="l"/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {l} = useContentInjected()
|
||||
</script>
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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()],
|
||||
// },
|
||||
})
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
<!-- <!– SSR-safe: iframe appears only in the browser –>-->
|
||||
<!-- <!– <ClientOnly>-->
|
||||
<!-- <iframe-->
|
||||
<!-- id="iframeCapCorn"-->
|
||||
<!-- src="https://www.capcorn.net/MasterReq?MB=1487&FL=17&LG=0"-->
|
||||
<!-- frameborder="0"-->
|
||||
<!-- width="100%"-->
|
||||
<!-- scrolling="auto"-->
|
||||
<!-- />-->
|
||||
<!-- </ClientOnly> –>-->
|
||||
</AppFlatSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useSeoLinking()
|
||||
const {l} = useContentInjected()
|
||||
|
||||
useSeoMeta({
|
||||
title: () => l.value.meta.title,
|
||||
description: () => l.value.meta.description,
|
||||
})
|
||||
</script>
|
||||
@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Contact‑centric layout -->
|
||||
<AppFlatSection>
|
||||
<!-- Grid: form (fixed max‑width) | 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>
|
||||
BIN
apps/panoramablick-saalbach.at/public/bruendl/de-mobile.jpg
Normal file
BIN
apps/panoramablick-saalbach.at/public/bruendl/de-mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
BIN
apps/panoramablick-saalbach.at/public/bruendl/de.jpg
Normal file
BIN
apps/panoramablick-saalbach.at/public/bruendl/de.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 698 KiB |
BIN
apps/panoramablick-saalbach.at/public/bruendl/en-mobile.jpg
Normal file
BIN
apps/panoramablick-saalbach.at/public/bruendl/en-mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 501 KiB |
BIN
apps/panoramablick-saalbach.at/public/bruendl/en.jpg
Normal file
BIN
apps/panoramablick-saalbach.at/public/bruendl/en.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 710 KiB |
53
package.json
53
package.json
@ -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
7
packages/code/eslint.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
export default [
|
||||
{
|
||||
rules: {
|
||||
'import/no-unresolved': ['error', { ignore: ['^virtual:'] }],
|
||||
},
|
||||
},
|
||||
]
|
||||
11
packages/code/package.json
Normal file
11
packages/code/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
packages/code/prettier.mjs
Normal file
7
packages/code/prettier.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# `@turbo/eslint-config`
|
||||
|
||||
Collection of internal eslint configurations.
|
||||
@ -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/**"],
|
||||
},
|
||||
];
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
39
packages/eslint-config/react-internal.js
vendored
39
packages/eslint-config/react-internal.js
vendored
@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -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
|
||||
@ -1,2 +0,0 @@
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
@ -1 +0,0 @@
|
||||
typescript.includeWorkspace = true
|
||||
@ -1,5 +0,0 @@
|
||||
export default defineAppConfig({
|
||||
myLayer: {
|
||||
name: 'My amazing Nuxt layer (overwritten)'
|
||||
}
|
||||
})
|
||||
@ -1,3 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage/>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@ -0,0 +1,2 @@
|
||||
content:
|
||||
kik: kokk
|
||||
@ -0,0 +1,5 @@
|
||||
<script setup>
|
||||
const { g, l } = useContentInjected()
|
||||
</script>
|
||||
|
||||
<template>{{ g }} {{ l }}</template>
|
||||
@ -0,0 +1 @@
|
||||
hey: thereee
|
||||
@ -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!"
|
||||
6
packages/layers/content/.playground/eslint.config.mjs
Normal file
6
packages/layers/content/.playground/eslint.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -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>
|
||||
17
packages/layers/content/.playground/tsconfig.json
Normal file
17
packages/layers/content/.playground/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
1
packages/layers/content/TODO
Normal file
1
packages/layers/content/TODO
Normal file
@ -0,0 +1 @@
|
||||
adding a page in the playground pages/[locale]/[variant] prevents the panoramablick app to start in dev (eslint)?!
|
||||
1
packages/layers/content/app/app.config.ts
Normal file
1
packages/layers/content/app/app.config.ts
Normal file
@ -0,0 +1 @@
|
||||
export default defineAppConfig({})
|
||||
@ -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() {
|
||||
@ -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') {
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
2
packages/layers/content/app/content.global.yaml
Normal file
2
packages/layers/content/app/content.global.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
content:
|
||||
hey: lologlob
|
||||
@ -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('/')
|
||||
)
|
||||
}
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
90
packages/layers/content/app/utils/content-files.ts
Normal file
90
packages/layers/content/app/utils/content-files.ts
Normal 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))
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
19
packages/layers/content/app/utils/content-types.d.ts
vendored
Normal file
19
packages/layers/content/app/utils/content-types.d.ts
vendored
Normal 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
|
||||
}
|
||||
@ -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!"
|
||||
@ -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>
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user