refactor(vue): migrate app, routes, and tooling from Svelte

Replace the Svelte runtime, Vite plugin, Storybook adapter, and component files with Vue 3 equivalents while preserving the existing route table, page markup, styling, assets, deployment shape, and npm workflows.

Move shared and page-level state onto Vue refs, computed values, lifecycle hooks, and script setup components. Recreate the custom async router and translation composable as Vue-native modules so navigation and bilingual content continue to work without introducing a router or state-management dependency.

Adopt the maintained @lucide/vue package, remove the Svelte icon and map wrappers, and initialize MapLibre directly on the Helpdesk page. Replace the Svelte CSF story with a Vue Storybook story, remove obsolete aliases and generation/config files, and update the audited lockfile with no known vulnerabilities.
This commit is contained in:
topsinoty
2026-07-04 15:51:11 +03:00
parent 33eac3b896
commit 13322e11cb
55 changed files with 3684 additions and 2456 deletions

View File

@@ -1,9 +1,8 @@
/** @type { import('@storybook/svelte-vite').StorybookConfig } */ /** @type { import('@storybook/vue3-vite').StorybookConfig } */
const config = { const config = {
stories: ['../src/**/*.stories.svelte'], stories: ['../src/**/*.stories.js'],
addons: ['@storybook/addon-svelte-csf'],
framework: { framework: {
name: '@storybook/svelte-vite', name: '@storybook/vue3-vite',
options: {}, options: {},
}, },
}; };

View File

@@ -1,4 +1,4 @@
/** @type { import('@storybook/svelte-vite').Preview } */ /** @type { import('@storybook/vue3-vite').Preview } */
const preview = { const preview = {
parameters: { parameters: {
controls: { controls: {

2462
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,25 @@
"preview": "vite preview", "preview": "vite preview",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"generate-stories": "node scripts/generate-stories.js",
"optimise": "node scripts/optimise-images.js" "optimise": "node scripts/optimise-images.js"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-svelte-csf": "^5.0.11", "@storybook/vue3-vite": "^10.2.15",
"@storybook/svelte-vite": "^10.2.15",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@vitejs/plugin-vue": "^6.0.2",
"storybook": "^10.2.15", "storybook": "^10.2.15",
"svelte": "^5.43.8", "vite": "npm:rolldown-vite@7.2.5",
"vite": "npm:rolldown-vite@7.2.5" "vue": "^3.5.25"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.2.5"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "^1.23.0",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"lucide-svelte": "^0.554.0",
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.19.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"svelte-maplibre": "^1.2.6",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"
} }
} }

View File

@@ -1,37 +0,0 @@
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const svelteSrc = readFileSync(resolve(__dirname, '../src/components/Svg.svelte'), 'utf-8');
const match = svelteSrc.match(/export const VARIANTS\s*=\s*\[([^\]]+)\]/);
if (!match) {
console.error('Could not find VARIANTS in Svg.svelte');
process.exit(1);
}
const variants = match[1]
.split(',')
.map(v => v.trim().replace(/['"]/g, ''))
.filter(Boolean);
const stories = variants.map(v => `<Story name="${v}" args={{ type: '${v}' }} />`).join('\n');
const output = `<script context="module">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Svg from '../components/Svg.svelte';
const { Story } = defineMeta({
title: 'Components/Svg',
component: Svg,
});
</script>
${stories}
`;
const outPath = resolve(__dirname, '../src/stories/Svg.stories.svelte');
writeFileSync(outPath, output, 'utf-8');
console.log(`✅ Generated ${variants.length} stories: ${variants.join(', ')}`);

View File

@@ -1,16 +0,0 @@
<script>
import { Router, text } from "$lib";
import { routes } from "./routes/index.js";
import Navbar from "./layout/Navbar.svelte";
import Footer from "./layout/Footer.svelte";
</script>
{#if $text && Object.keys($text).length > 0}
<Navbar />
<main class="min-h-screen flex flex-col">
<div class="flex-1 flex flex-col">
<Router {routes} />
</div>
<Footer />
</main>
{/if}

18
src/App.vue Normal file
View File

@@ -0,0 +1,18 @@
<script setup>
import { Router, text } from "./lib/index.js";
import { routes } from "./routes/index.js";
import Navbar from "./layout/Navbar.vue";
import Footer from "./layout/Footer.vue";
</script>
<template>
<template v-if="Object.keys(text).length > 0">
<Navbar />
<main class="min-h-screen flex flex-col">
<div class="flex-1 flex flex-col">
<Router :routes="routes" />
</div>
<Footer />
</main>
</template>
</template>

View File

@@ -1,18 +1,18 @@
<script> <script setup>
let { defineOptions({ inheritAttrs: false });
onClick = () => {}, defineProps({ onClick: { type: Function, default: () => {} } });
class: className = "",
children,
} = $props();
</script> </script>
<template>
<button <button
onclick={onClick} @click="onClick"
v-bind="$attrs"
data-animation data-animation
class="btn flex p-2.5 {className}" :class="['btn flex p-2.5', $attrs.class]"
> >
{@render children?.()} <slot />
</button> </button>
</template>
<style> <style>
.btn { .btn {

View File

@@ -1,68 +0,0 @@
<script>
import Button from "./Button.svelte";
let {
name = "",
buttonClass = "",
panelClass = "",
children,
} = $props();
let isOpen = $state(false);
let panelElement = $state(null);
const toggle = () => (isOpen = !isOpen);
const close = () => (isOpen = false);
function handleFocusOut({ relatedTarget, currentTarget }) {
if (
relatedTarget instanceof HTMLElement &&
currentTarget.contains(relatedTarget)
)
return;
close();
}
$effect(() => {
if (!isOpen) return;
const handleDocumentClick = (event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !panelElement) return;
if (
panelElement.contains(target) &&
target.closest("button, a, [role='menuitem']")
) {
close();
}
};
document.addEventListener("click", handleDocumentClick);
return () => document.removeEventListener("click", handleDocumentClick);
});
</script>
<div class="relative inline-block" onfocusout={handleFocusOut}>
<Button onClick={toggle} class={`${buttonClass} gap-0`}>
{name}
<span
class="ml-1 mr-0 mt-0.5 transition-transform inline-block text-[0.9em] opacity-60"
style:transform={isOpen ? "rotate(180deg)" : "none"}
aria-hidden="true"
>
</span>
</Button>
<div
class="{panelClass} absolute mt-2 rounded-md shadow-lg overflow-visible flex flex-col"
style:visibility={isOpen ? "visible" : "hidden"}
bind:this={panelElement}
>
<div>
{@render children?.()}
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue';
import Button from "./Button.vue";
defineProps({
name: { type: String, default: '' },
buttonClass: { type: String, default: '' },
panelClass: { type: String, default: '' },
});
const isOpen = ref(false);
const panelElement = ref(null);
const toggle = () => (isOpen.value = !isOpen.value);
const close = () => (isOpen.value = false);
function handleFocusOut({ relatedTarget, currentTarget }) {
if (
relatedTarget instanceof HTMLElement &&
currentTarget.contains(relatedTarget)
)
return;
close();
}
const handleDocumentClick = (event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !panelElement.value) return;
if (
panelElement.value.contains(target) &&
target.closest("button, a, [role='menuitem']")
) {
close();
}
};
watch(isOpen, (open) => {
document[open ? 'addEventListener' : 'removeEventListener']('click', handleDocumentClick);
});
onBeforeUnmount(() => document.removeEventListener('click', handleDocumentClick));
</script>
<template>
<div class="relative inline-block" @focusout="handleFocusOut">
<Button :onClick="toggle" :class="`${buttonClass} gap-0`">
{{ name }}
<span
class="ml-1 mr-0 mt-0.5 transition-transform inline-block text-[0.9em] opacity-60"
:style="{ transform: isOpen ? 'rotate(180deg)' : 'none' }"
aria-hidden="true"
>
</span>
</Button>
<div
:class="[panelClass, 'absolute mt-2 rounded-md shadow-lg overflow-visible flex flex-col']"
:style="{ visibility: isOpen ? 'visible' : 'hidden' }"
ref="panelElement"
>
<div>
<slot />
</div>
</div>
</div>
</template>

View File

@@ -1,59 +0,0 @@
<!--
Multi-purpose image component with optional link wrapper,
hover effects, size variants, and object-fit options. Includes support for WebP format with fallback to standard formats.
-->
<script>
export let webpSrc = ""; // Optional WebP source for browsers that support it
export let src = ""; // Fallback (some devices don't support WebP) or primary source if webpSrc is not provided
export let alt = "";
export let href = null; // optional link URL
export let target = "_blank";
export let rel = "noopener noreferrer";
export let objectFit = "contain"; // 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'
export let hover = false;
// optional srcset for responsive images (w descriptors means width-based, x descriptors means pixel density-based)
// e.g. "image-400.jpg 400w, image-800.jpg 800w"
export let srcSet = ""; // always set src if using this
export let webpSrcSet = ""; // always set src if using this
export let pictureClass = ""; // CSS/Tailwind classes applied to the <picture> element
$: objectFitClass = {
contain: "object-contain",
cover: "object-cover",
fill: "object-fill",
none: "object-none",
"scale-down": "object-scale-down"
}[objectFit];
$: hoverClasses = hover ? "transition-all duration-200 hover:scale-105 hover:opacity-90" : "";
</script>
{#if href}
<a {href} {target} {rel} class="inline-block leading-none">
<picture class={pictureClass}>
{#if webpSrc || webpSrcSet}
<source srcset={webpSrcSet || webpSrc} type="image/webp" />
{/if}
<img
{src}
srcset={srcSet || undefined}
alt={alt}
class="block h-auto max-w-full {objectFitClass} {hoverClasses} {$$props.class ?? ''}"
/>
</picture>
</a>
{:else}
<picture class={pictureClass}>
{#if webpSrc || webpSrcSet}
<source srcset={webpSrcSet || webpSrc} type="image/webp" />
{/if}
<img
{src}
srcset={srcSet || undefined}
alt={alt}
class="block h-auto max-w-full {objectFitClass} {hoverClasses} {$$props.class ?? ''}"
/>
</picture>
{/if}

65
src/components/Image.vue Normal file
View File

@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue';
defineOptions({ inheritAttrs: false });
const props = defineProps({
webpSrc: { type: String, default: '' },
src: { type: String, default: '' },
alt: { type: String, default: '' },
href: { type: String, default: null },
target: { type: String, default: '_blank' },
rel: { type: String, default: 'noopener noreferrer' },
objectFit: { type: String, default: 'contain' },
hover: { type: Boolean, default: false },
srcSet: { type: String, default: '' },
webpSrcSet: { type: String, default: '' },
pictureClass: { type: String, default: '' },
});
const objectFitClass = computed(() => ({
contain: "object-contain",
cover: "object-cover",
fill: "object-fill",
none: "object-none",
"scale-down": "object-scale-down"
}[props.objectFit]));
const hoverClasses = computed(() => props.hover ? "transition-all duration-200 hover:scale-105 hover:opacity-90" : "");
</script>
<template>
<!--
Multi-purpose image component with optional link wrapper,
hover effects, size variants, and object-fit options. Includes support for WebP format with fallback to standard formats.
-->
<template v-if="href">
<a :href="href" :target="target" :rel="rel" class="inline-block leading-none">
<picture :class="pictureClass">
<template v-if="webpSrc || webpSrcSet">
<source :srcset="webpSrcSet || webpSrc" type="image/webp" />
</template>
<img
:src="src"
:srcset="srcSet || undefined"
:alt="alt"
:class="['block h-auto max-w-full', objectFitClass, hoverClasses, $attrs.class]"
v-bind="$attrs"
/>
</picture>
</a>
</template><template v-else>
<picture :class="pictureClass">
<template v-if="webpSrc || webpSrcSet">
<source :srcset="webpSrcSet || webpSrc" type="image/webp" />
</template>
<img
:src="src"
:srcset="srcSet || undefined"
:alt="alt"
:class="['block h-auto max-w-full', objectFitClass, hoverClasses, $attrs.class]"
v-bind="$attrs"
/>
</picture>
</template>
</template>

View File

@@ -1,158 +0,0 @@
<script>
import { onDestroy } from 'svelte';
import Image from './Image.svelte';
/**
* Images can be either strings or objects with optimization options:
*
* String format (simple):
* images={["image1.jpg", "image2.jpg"]}
*
* Object format (multiple image options):
* images={[
* { src: "image1.jpg", webpSrc: "image1.webp", alt: "Description" },
* { src: "image2.jpg", webpSrc: "image2.webp", alt: "Description" }
* ]}
*
*/
export let images = [];
export let width = '100%';
export let height = '100%';
export let interactive = false;
// automatic cycling options
export let autoplay = false;
export let interval = 3000;
export let loop = true;
export let startIndex = 0;
let current = startIndex;
let timer;
$: currentImage = images[current];
$: isObjectImage = typeof currentImage === 'object' && currentImage !== null;
$: imageSrc = typeof currentImage === 'string' ? currentImage : currentImage?.src || '';
$: imageAlt = typeof currentImage === 'string' ? '' : currentImage?.alt || '';
$: imageWebpSrc = isObjectImage ? currentImage?.webpSrc || '' : '';
$: imageSrcSet = isObjectImage ? currentImage?.srcSet || '' : '';
$: imageWebpSrcSet = isObjectImage ? currentImage?.webpSrcSet || '' : '';
$: imageClass = isObjectImage ? currentImage?.class || '' : '';
const next = () => {
if (current < images.length - 1) {
current += 1;
} else if (loop) {
current = 0;
}
};
const prev = () => {
if (current > 0) {
current -= 1;
} else if (loop) {
current = images.length - 1;
}
};
// react to autoplay or interval changes
$: {
clearInterval(timer);
if (autoplay && images.length > 1) {
timer = setInterval(next, interval);
}
}
onDestroy(() => {
clearInterval(timer);
});
</script>
<div
class="slideshow"
style="width: {width}; height: {height};"
aria-live="polite"
>
{#if images.length}
<Image
src={imageSrc}
alt={imageAlt}
webpSrc={imageWebpSrc}
srcSet={imageSrcSet}
webpSrcSet={imageWebpSrcSet}
objectFit="cover"
class={`w-full h-full block ${imageClass}`}
/>
{#if interactive && images.length > 1}
<button class="prev" on:click={prev} aria-label="Previous">&#10094;</button>
<button class="next" on:click={next} aria-label="Next">&#10095;</button>
<div class="indicators">
{#each images as _, i}
<button
type="button"
class:active={i === current}
on:click={() => (current = i)}
aria-label={`Go to slide ${i + 1}`}
></button>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.slideshow {
position: relative;
overflow: hidden;
display: block;
}
/* interactive controls */
.prev,
.next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 0.5rem 0.75rem;
cursor: pointer;
z-index: 1;
}
.prev {
left: 0.5rem;
}
.next {
right: 0.5rem;
}
.indicators {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
}
.indicators button {
width: 0.75rem;
height: 0.75rem;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: background 0.2s;
padding: 0;
}
.indicators button.active {
background: white;
}
</style>

View File

@@ -0,0 +1,156 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import Image from './Image.vue';
/**
* Images can be either strings or objects with optimization options:
*
* String format (simple):
* images={["image1.jpg", "image2.jpg"]}
*
* Object format (multiple image options):
* images={[
* { src: "image1.jpg", webpSrc: "image1.webp", alt: "Description" },
* { src: "image2.jpg", webpSrc: "image2.webp", alt: "Description" }
* ]}
*
*/
const props = defineProps({
images: { type: Array, default: () => [] },
width: { type: String, default: '100%' },
height: { type: String, default: '100%' },
interactive: { type: Boolean, default: false },
autoplay: { type: Boolean, default: false },
interval: { type: Number, default: 3000 },
loop: { type: Boolean, default: true },
startIndex: { type: Number, default: 0 },
});
const current = ref(props.startIndex);
let timer;
const currentImage = computed(() => props.images[current.value]);
const isObjectImage = computed(() => typeof currentImage.value === 'object' && currentImage.value !== null);
const imageSrc = computed(() => typeof currentImage.value === 'string' ? currentImage.value : currentImage.value?.src || '');
const imageAlt = computed(() => typeof currentImage.value === 'string' ? '' : currentImage.value?.alt || '');
const imageWebpSrc = computed(() => isObjectImage.value ? currentImage.value?.webpSrc || '' : '');
const imageSrcSet = computed(() => isObjectImage.value ? currentImage.value?.srcSet || '' : '');
const imageWebpSrcSet = computed(() => isObjectImage.value ? currentImage.value?.webpSrcSet || '' : '');
const imageClass = computed(() => isObjectImage.value ? currentImage.value?.class || '' : '');
const next = () => {
if (current.value < props.images.length - 1) {
current.value += 1;
} else if (props.loop) {
current.value = 0;
}
};
const prev = () => {
if (current.value > 0) {
current.value -= 1;
} else if (props.loop) {
current.value = props.images.length - 1;
}
};
// react to autoplay or interval changes
watch(() => [props.autoplay, props.interval, props.images.length], () => {
clearInterval(timer);
if (props.autoplay && props.images.length > 1) {
timer = setInterval(next, props.interval);
}
}, { immediate: true });
onBeforeUnmount(() => clearInterval(timer));
</script>
<template>
<div
class="slideshow"
:style="{ width, height }"
aria-live="polite"
>
<template v-if="images.length">
<Image
:src="imageSrc"
:alt="imageAlt"
:webpSrc="imageWebpSrc"
:srcSet="imageSrcSet"
:webpSrcSet="imageWebpSrcSet"
objectFit="cover"
:class="`w-full h-full block ${imageClass}`"
/>
<template v-if="interactive && images.length > 1">
<button class="prev" @click="prev" aria-label="Previous">&#10094;</button>
<button class="next" @click="next" aria-label="Next">&#10095;</button>
<div class="indicators">
<template v-for="(_, i) in images">
<button
type="button"
:class="{ 'active': i === current }"
@click="current = i"
:aria-label="`Go to slide ${i + 1}`"
></button>
</template>
</div>
</template>
</template>
</div>
</template>
<style>
.slideshow {
position: relative;
overflow: hidden;
display: block;
}
/* interactive controls */
.prev,
.next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
padding: 0.5rem 0.75rem;
cursor: pointer;
z-index: 1;
}
.prev {
left: 0.5rem;
}
.next {
right: 0.5rem;
}
.indicators {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
}
.indicators button {
width: 0.75rem;
height: 0.75rem;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: background 0.2s;
padding: 0;
}
.indicators button.active {
background: white;
}
</style>

View File

@@ -1,4 +1,4 @@
<script context="module"> <script>
export const VARIANTS = [ export const VARIANTS = [
'instagram', 'instagram',
'facebook', 'facebook',
@@ -20,69 +20,71 @@
]; ];
</script> </script>
<script> <script setup>
export let type; defineOptions({ inheritAttrs: false });
defineProps({ type: { type: String, required: true } });
</script> </script>
<template>
<!-- Circuitboard connectors--> <!-- Circuitboard connectors-->
{#if type === "connector"} <template v-if="type === 'connector'">
<svg viewBox="0 0 182 66" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 182 66" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/> <path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/>
<circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "connector_2"} </template><template v-else-if="type === 'connector_2'">
<svg viewBox="0 0 170 37" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 170 37" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 2.5H67.5L87 27.5H153.5" stroke="currentColor" stroke-width="5"/> <path d="M0 2.5H67.5L87 27.5H153.5" stroke="currentColor" stroke-width="5"/>
<circle cx="161" cy="27.5" r="6.5" stroke="currentColor" stroke-width="5"/> <circle cx="161" cy="27.5" r="6.5" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "mirrorV"} </template><template v-else-if="type === 'mirrorV'">
<svg viewBox="0 0 182 66" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 182 66" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1) translate(0,-66)"> <g transform="scale(1,-1) translate(0,-66)">
<path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/> <path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/>
<circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/>
</g> </g>
</svg> </svg>
{:else if type === "mirrorH"} </template><template v-else-if="type === 'mirrorH'">
<svg viewBox="0 0 182 66" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 182 66" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(-1,1) translate(-182,0)"> <g transform="scale(-1,1) translate(-182,0)">
<path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/> <path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/>
<circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/>
</g> </g>
</svg> </svg>
{:else if type === "mirrorHR"} </template><template v-else-if="type === 'mirrorHR'">
<svg viewBox="0 0 66 182" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 66 182" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63.5 0V67.5L15.5 102.5V156" stroke="currentColor" stroke-width="5"/> <path d="M63.5 0V67.5L15.5 102.5V156" stroke="currentColor" stroke-width="5"/>
<circle cx="15.5" cy="166" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="15.5" cy="166" r="10" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "mirrorVH"} </template><template v-else-if="type === 'mirrorVH'">
<svg viewBox="0 0 182 66" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 182 66" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(-1,-1) translate(-182,-66)"> <g transform="scale(-1,-1) translate(-182,-66)">
<path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/> <path d="M0 63.5H67.5L102.5 15.5H156" stroke="currentColor" stroke-width="5"/>
<circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="166" cy="15.5" r="10" stroke="currentColor" stroke-width="5"/>
</g> </g>
</svg> </svg>
{:else if type === "connectorHL"} </template><template v-else-if="type === 'connectorHL'">
<svg viewBox="0 0 80 380" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 80 380" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 0 V200 L63.5 235 V355" stroke="currentColor" stroke-width="5"/> <path d="M15.5 0 V200 L63.5 235 V355" stroke="currentColor" stroke-width="5"/>
<circle cx="63.5" cy="365" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="63.5" cy="365" r="10" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "branch"} </template><template v-else-if="type === 'branch'">
<svg viewBox="0 0 75 74" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 75 74" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 31.4784H46L68 1.47842M45.5 30.9784L66.5 63.9784" <path d="M0 31.4784H46L68 1.47842M45.5 30.9784L66.5 63.9784"
stroke="currentColor" stroke="currentColor"
stroke-width="5"/> stroke-width="5"/>
</svg> </svg>
{:else if type === "branchH"} </template><template v-else-if="type === 'branchH'">
<svg viewBox="0 0 75 74" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 75 74" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(-1,1) translate(-75,0)"> <g transform="scale(-1,1) translate(-75,0)">
<path d="M0 31.4784H46L68 1.47842M45.5 30.9784L66.5 63.9784" <path d="M0 31.4784H46L68 1.47842M45.5 30.9784L66.5 63.9784"
stroke="currentColor" stroke="currentColor"
@@ -90,34 +92,34 @@
</g> </g>
</svg> </svg>
{:else if type === "straight_tiny"} </template><template v-else-if="type === 'straight_tiny'">
<svg viewBox="0 0 58 18" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 58 18" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="48.0312" cy="9" r="6.5" stroke="currentColor" stroke-width="5"/> <circle cx="48.0312" cy="9" r="6.5" stroke="currentColor" stroke-width="5"/>
<path d="M0.03125 9.5L40.0312 9" stroke="currentColor" stroke-width="5"/> <path d="M0.03125 9.5L40.0312 9" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "straightH"} </template><template v-else-if="type === 'straightH'">
<svg viewBox="0 0 30 150" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 30 150" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 0 V125" stroke="currentColor" stroke-width="5"/> <path d="M15 0 V125" stroke="currentColor" stroke-width="5"/>
<circle cx="15" cy="135" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="15" cy="135" r="10" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "straightHS"} </template><template v-else-if="type === 'straightHS'">
<svg viewBox="0 0 30 90" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 30 90" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 0 V65" stroke="currentColor" stroke-width="5"/> <path d="M15 0 V65" stroke="currentColor" stroke-width="5"/>
<circle cx="15" cy="75" r="10" stroke="currentColor" stroke-width="5"/> <circle cx="15" cy="75" r="10" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "straightL_connectorL"} </template><template v-else-if="type === 'straightL_connectorL'">
<svg viewBox="0 0 1025 56" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 1025 56" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="789.075" cy="45.7383" r="7.0785" stroke="currentColor" stroke-width="5"/> <circle cx="789.075" cy="45.7383" r="7.0785" stroke="currentColor" stroke-width="5"/>
<path d="M0.000976562 46L780.363 45.738" stroke="currentColor" stroke-width="5"/> <path d="M0.000976562 46L780.363 45.738" stroke="currentColor" stroke-width="5"/>
<circle cx="1014.5" cy="9.801" r="7.0785" stroke="currentColor" stroke-width="5"/> <circle cx="1014.5" cy="9.801" r="7.0785" stroke="currentColor" stroke-width="5"/>
<path d="M482.521 45.1935L539.149 9.80103H1006.87" stroke="currentColor" stroke-width="5"/> <path d="M482.521 45.1935L539.149 9.80103H1006.87" stroke="currentColor" stroke-width="5"/>
</svg> </svg>
{:else if type === "straight_connector"} </template><template v-else-if="type === 'straight_connector'">
<svg viewBox="0 0 391 41" class={$$props.class ?? ''} fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 391 41" :class="$attrs.class" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="266.005" cy="32" r="6.5" stroke="currentColor" stroke-width="5"/> <circle cx="266.005" cy="32" r="6.5" stroke="currentColor" stroke-width="5"/>
<circle cx="382.005" cy="9" r="6.5" stroke="currentColor" stroke-width="5"/> <circle cx="382.005" cy="9" r="6.5" stroke="currentColor" stroke-width="5"/>
<path d="M0.00485229 32.5L258.005 32" stroke="currentColor" stroke-width="5"/> <path d="M0.00485229 32.5L258.005 32" stroke="currentColor" stroke-width="5"/>
@@ -127,25 +129,26 @@
<!-- End of circuitboard connectors --> <!-- End of circuitboard connectors -->
<!-- Social media icons --> <!-- Social media icons -->
{:else if type === "instagram"} </template><template v-else-if="type === 'instagram'">
<svg class="w-8 h-8 {$$props.class ?? ''}" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg :class="['w-8 h-8', $attrs.class]" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/> <path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg> </svg>
{:else if type === "facebook"} </template><template v-else-if="type === 'facebook'">
<svg class="w-8 h-8 {$$props.class ?? ''}" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg :class="['w-8 h-8', $attrs.class]" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/> <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg> </svg>
{:else if type === "github"} </template><template v-else-if="type === 'github'">
<svg class="w-8 h-8 {$$props.class ?? ''}" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg :class="['w-8 h-8', $attrs.class]" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg> </svg>
<!-- Social media icons --> <!-- Social media icons -->
{:else} </template><template v-else>
<div>Unknown type: {type}</div> <div>Unknown type: {{ type }}</div>
{/if} </template>
</template>

View File

@@ -1,14 +1,14 @@
// layout components // layout components
export { default as Container } from './layout/Container.svelte' export { default as Container } from './layout/Container.vue'
export { default as Card } from './layout/Card.svelte' export { default as Card } from './layout/Card.vue'
export { default as Stack } from './layout/Stack.svelte' export { default as Stack } from './layout/Stack.vue'
export { default as Grid } from './layout/Grid.svelte' export { default as Grid } from './layout/Grid.vue'
export { default as Center } from './layout/Center.svelte' export { default as Center } from './layout/Center.vue'
export { default as Section } from './layout/Section.svelte' export { default as Section } from './layout/Section.vue'
// components // components
export { default as Button } from './Button.svelte' export { default as Button } from './Button.vue'
export { default as Dropdown } from './Dropdown.svelte' export { default as Dropdown } from './Dropdown.vue'
export { default as Slideshow } from './Slideshow.svelte' export { default as Slideshow } from './Slideshow.vue'
export { default as Svg } from './Svg.svelte' export { default as Svg } from './Svg.vue'
export { default as Image } from './Image.svelte' export { default as Image } from './Image.vue'

View File

@@ -1,10 +1,9 @@
<script> <script setup>
let { defineOptions({ inheritAttrs: false });
href = "", defineProps({
variant = "default", // 'default' | 'animated' | 'animated-neutral' | 'glass' | 'blur' href: { type: String, default: '' },
class: className = "", variant: { type: String, default: 'default' },
children, });
} = $props();
const variantClasses = { const variantClasses = {
default: "card-default", default: "card-default",
@@ -15,15 +14,17 @@
}; };
</script> </script>
{#if href} <template>
<a {href} class={`card ${variantClasses[variant]} ${className}`}> <template v-if="href">
{@render children?.()} <a :href="href" :class="['card', variantClasses[variant], $attrs.class]" v-bind="$attrs">
<slot />
</a> </a>
{:else} </template><template v-else>
<div class={`card ${variantClasses[variant]} ${className}`}> <div :class="['card', variantClasses[variant], $attrs.class]" v-bind="$attrs">
{@render children?.()} <slot />
</div> </div>
{/if} </template>
</template>
<style> <style>
.card { .card {

View File

@@ -1,19 +0,0 @@
<script>
let {
dir = "row", // 'row' | 'col' (default: 'row')
gap = "gap-4",
class: className = "",
children,
} = $props();
</script>
<div
class={"flex " +
(dir === "row" ? "flex-row" : "flex-col") +
" items-center justify-center " +
gap +
" " +
className}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,16 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
dir: { type: String, default: 'row' },
gap: { type: String, default: 'gap-4' },
});
</script>
<template>
<div
:class="['flex items-center justify-center', dir === 'row' ? 'flex-row' : 'flex-col', gap, $attrs.class]"
v-bind="$attrs"
>
<slot />
</div>
</template>

View File

@@ -1,19 +0,0 @@
<!--
A responsive container that centers content and optionally
removes the max-width when `fluid` is true. Use the `class`
add additional CSS classes to the root element. Content is
projected via the default slot.
-->
<script>
let {
maxWidth = false, // true removes max-width
center = true, // false prevents centering and removes horizontal padding
class: className = "",
children,
} = $props();
</script>
<div class="w-full {center ? 'px-(--page-padding-inline)' : ''} {maxWidth ? '' : 'max-w-(--page-max-width)'} {center ? 'mx-auto' : ''} {className}">
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
maxWidth: { type: Boolean, default: false },
center: { type: Boolean, default: true },
});
</script>
<template>
<!--
A responsive container that centers content and optionally
removes the max-width when `fluid` is true. Use the `class`
add additional CSS classes to the root element. Content is
projected via the default slot.
-->
<div :class="['w-full', center && 'px-(--page-padding-inline) mx-auto', !maxWidth && 'max-w-(--page-max-width)', $attrs.class]" v-bind="$attrs">
<slot />
</div>
</template>

View File

@@ -1,40 +1,38 @@
<script> <script setup>
let { import { computed } from 'vue';
min = "220px", // min column width (used when columns not set) defineOptions({ inheritAttrs: false });
columns = null, // fixed number of columns for desktop const props = defineProps({
tabletColumns = null, // fixed number of columns for tablet / mid screens min: { type: String, default: '220px' },
largeColumns = null, // fixed number of columns for large desktop columns: { type: [String, Number], default: null },
mobileColumns = null, // fixed number of columns for mobile tabletColumns: { type: [String, Number], default: null },
gap = "gap-3", largeColumns: { type: [String, Number], default: null },
class: className = "", mobileColumns: { type: [String, Number], default: null },
children, gap: { type: String, default: 'gap-3' },
} = $props(); });
const hasTabletColumns = computed(() => props.tabletColumns !== null);
const hasTabletColumns = $derived(tabletColumns !== null); const tabletCols = computed(() => props.tabletColumns ? `repeat(${props.tabletColumns}, 1fr)` : '1fr');
const tabletCols = $derived( const desktopColumns = computed(() => props.columns ? `repeat(${props.columns}, 1fr)` : '1fr');
tabletColumns ? `repeat(${tabletColumns}, 1fr)` : "1fr" const largeDesktopColumns = computed(() => props.largeColumns ? `repeat(${props.largeColumns}, 1fr)` : desktopColumns.value);
); const mobileCols = computed(() => props.mobileColumns ? `repeat(${props.mobileColumns}, 1fr)` : '1fr');
const desktopColumns = $derived(columns ? `repeat(${columns}, 1fr)` : "1fr");
const largeDesktopColumns = $derived(
largeColumns ? `repeat(${largeColumns}, 1fr)` : desktopColumns
);
const mobileCols = $derived(mobileColumns ? `repeat(${mobileColumns}, 1fr)` : "1fr");
</script> </script>
<template>
<div <div
class="grid {className} {gap}" :class="['grid', gap, $attrs.class]"
data-has-tablet={hasTabletColumns ? "true" : "false"} :data-has-tablet="hasTabletColumns ? 'true' : 'false'"
style=" :style="{
--min: {min}; '--min': min,
--tablet-columns: {tabletCols}; '--tablet-columns': tabletCols,
--desktop-columns: {desktopColumns}; '--desktop-columns': desktopColumns,
--large-desktop-columns: {largeDesktopColumns}; '--large-desktop-columns': largeDesktopColumns,
--mobile-columns: {mobileCols}; '--mobile-columns': mobileCols,
grid-template-columns: var(--mobile-columns); gridTemplateColumns: 'var(--mobile-columns)',
" }"
v-bind="$attrs"
> >
{@render children?.()} <slot />
</div> </div>
</template>
<style> <style>
/* Mobile first: use mobileColumns by default */ /* Mobile first: use mobileColumns by default */

View File

@@ -1,30 +0,0 @@
<script>
let {
bg = "",
background = "",
padding = "normal",
fullWidth = false,
id = "",
class: className = "",
contentClass = "",
children
} = $props();
const paddingClasses = {
none: "py-0",
tight: "py-2 md:py-3",
small: "py-4 md:py-8",
normal: "py-12 max-md:py-8",
large: "py-[clamp(3rem,8vw,6rem)] max-md:py-12",
};
</script>
<section
{id}
class="relative w-full {bg} {className}"
style={background ? `background: ${background};` : undefined}
>
<div class="w-full {fullWidth ? '' : 'max-w-(--page-max-width)'} mx-auto px-(--page-padding-inline) {paddingClasses[padding]} {contentClass}">
{@render children?.()}
</div>
</section>

View File

@@ -0,0 +1,32 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
bg: { type: String, default: '' },
background: { type: String, default: '' },
padding: { type: String, default: 'normal' },
fullWidth: { type: Boolean, default: false },
id: { type: String, default: '' },
contentClass: { type: String, default: '' },
});
const paddingClasses = {
none: "py-0",
tight: "py-2 md:py-3",
small: "py-4 md:py-8",
normal: "py-12 max-md:py-8",
large: "py-[clamp(3rem,8vw,6rem)] max-md:py-12",
};
</script>
<template>
<section
:id="id || undefined"
:class="['relative w-full', bg, $attrs.class]"
:style="background ? { background } : undefined"
v-bind="$attrs"
>
<div :class="['w-full mx-auto px-(--page-padding-inline)', !fullWidth && 'max-w-(--page-max-width)', paddingClasses[padding], contentClass]">
<slot />
</div>
</section>
</template>

View File

@@ -1,16 +0,0 @@
<!--
A simple stack/column layout that stacks children vertically.
-->
<script>
let {
gap = "gap-3",
align = "stretch",
class: className = "",
children
} = $props();
</script>
<div class="flex flex-col {className} {gap}" style="align-items: {align};">
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
gap: { type: String, default: 'gap-3' },
align: { type: String, default: 'stretch' },
});
</script>
<template>
<!--
A simple stack/column layout that stacks children vertically.
-->
<div :class="['flex flex-col', gap, $attrs.class]" :style="{ alignItems: align }" v-bind="$attrs">
<slot />
</div>
</template>

View File

@@ -1,39 +1,40 @@
<script> <script setup>
import { import {
Grid, Grid,
Section, Section,
Container, Container,
Center, Center,
Svg, Svg,
} from "$components"; } from "../components/index.js";
import { text } from "$lib"; import { text } from "../lib/index.js";
</script> </script>
<template>
<Section bg="bg-gray-900" class="text-white"> <Section bg="bg-gray-900" class="text-white">
<Grid> <Grid>
<div> <div>
<h3 class="text-3xl font-light pb-4">MTÜ Lapikud</h3> <h3 class="text-3xl font-light pb-4">MTÜ Lapikud</h3>
<Container center={false}> <Container :center="false">
<address class="not-italic pb-4"> <address class="not-italic pb-4">
{$text.footer?.address || "Aadress: "}<a href="https://maps.app.goo.gl/1pCDMVNaMD29G7y78" class="text-orange-500">{$text.footer?.addressValue || "Akadeemia tee 5, 12616 Tallinn, Eesti"}</a> {{ text.footer?.address || "Aadress: " }}<a href="https://maps.app.goo.gl/1pCDMVNaMD29G7y78" class="text-orange-500">{{ text.footer?.addressValue || "Akadeemia tee 5, 12616 Tallinn, Eesti" }}</a>
<br/> <br/>
{$text.footer?.mail || "E-post: "}<a href="mailto:lapikud@lapikud.ee" class="text-orange-500">lapikud@lapikud.ee</a> {{ text.footer?.mail || "E-post: " }}<a href="mailto:lapikud@lapikud.ee" class="text-orange-500">lapikud@lapikud.ee</a>
<br/> <br/>
{$text.footer?.phone || "Tel: "}<a href="tel:+37258160799" class="text-orange-500">+372 58 160 799</a> {{ text.footer?.phone || "Tel: " }}<a href="tel:+37258160799" class="text-orange-500">+372 58 160 799</a>
<br/> <br/>
{$text.footer?.messenger || "Messenger: "}<a href="https://m.me/Lapikud" target="_blank" rel="noopener noreferrer" class="text-orange-500">m.me/Lapikud</a> {{ text.footer?.messenger || "Messenger: " }}<a href="https://m.me/Lapikud" target="_blank" rel="noopener noreferrer" class="text-orange-500">m.me/Lapikud</a>
</address> </address>
<div> <div>
{$text.footer?.reg || "Reg. kood: "}<a href="https://ariregister.rik.ee/est/company/80167145/" class="text-orange-500">80167145</a> {{ text.footer?.reg || "Reg. kood: " }}<a href="https://ariregister.rik.ee/est/company/80167145/" class="text-orange-500">80167145</a>
<br/> <br/>
{$text.footer?.bank || "Swedbank"} EE812200221019551756 {{ text.footer?.bank || "Swedbank" }} EE812200221019551756
</div> </div>
</Container> </Container>
</div> </div>
<div> <div>
<Container center={false}> <Container :center="false">
<h3 class="text-3xl font-light flex justify-center pb-4">{$text.footer?.socials || "Sotsiaalmeedia"}</h3> <h3 class="text-3xl font-light flex justify-center pb-4">{{ text.footer?.socials || "Sotsiaalmeedia" }}</h3>
<div class="flex items-center justify-center gap-4"> <div class="flex items-center justify-center gap-4">
<a href="https://www.instagram.com/lapikud/" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="transition-opacity duration-200"> <a href="https://www.instagram.com/lapikud/" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="transition-opacity duration-200">
<Svg type="instagram"/> <Svg type="instagram"/>
@@ -48,5 +49,6 @@ import { text } from "$lib";
</Container> </Container>
</div> </div>
</Grid> </Grid>
<Center><small class="text-orange-500 pt-6">© 2026 {$text.footer?.organization || "MTÜ Lapikud"}</small></Center> <Center><small class="text-orange-500 pt-6">© 2026 {{ text.footer?.organization || "MTÜ Lapikud" }}</small></Center>
</Section> </Section>
</template>

View File

@@ -1,432 +0,0 @@
<script>
import {
Button,
Container,
Dropdown,
Stack,
} from "$components";
import { navigate, getPath, currentLang, text, switchLanguageRoute } from "$lib";
import { onDestroy } from "svelte";
// Icon imports (Lucide)
import Coffee from "lucide-svelte/icons/coffee";
import Presentation from "lucide-svelte/icons/presentation";
import Gamepad2 from "lucide-svelte/icons/gamepad-2";
import Lectern from "lucide-svelte/icons/lectern";
import Bot from "lucide-svelte/icons/bot";
import HandHeart from "lucide-svelte/icons/hand-heart";
import Trophy from "lucide-svelte/icons/trophy";
import ExternalLink from "lucide-svelte/icons/external-link";
import Menu from "lucide-svelte/icons/menu";
import X from "lucide-svelte/icons/x";
let mobileMenuOpen = $state(false);
let isClosing = $state(false);
let currentPath = $state(getPath());
const aboutRoutes = [
"/lapikutest",
"/aboutus",
"/tudengile",
"/student",
"/mentorid",
"/mentors",
"/juhatus",
"/management",
];
const eventsRoutes = ["/koolitused", "/workshops"];
function isCurrent(path) {
return currentPath === path;
}
function isInGroup(paths) {
return paths.includes(currentPath);
}
// combines styles for easier modifying
function navButtonClass(active = false) {
return active
? "border-transparent text-orange-500 hover:bg-white/5"
: "border-transparent text-white/75 hover:bg-white/5";
}
function dropdownButtonClass(active = false) {
return active
? "border-transparent text-orange-500 hover:bg-white/5"
: "border-transparent text-white/75 hover:bg-white/5";
}
function dropdownItemClass(active = false) {
return active
? "w-full justify-start gap-2 border-transparent text-orange-500"
: "w-full justify-start gap-2 border-transparent text-black hover:text-orange-500";
}
function dropdownPanelClass() {
return "nav-dropdown-panel min-w-52 border border-black/10 bg-white p-1 text-black";
}
// Update currentPath when navigation occurs
$effect(() => {
const handleNavigation = () => {
currentPath = getPath();
};
window.addEventListener("popstate", handleNavigation);
return () => window.removeEventListener("popstate", handleNavigation);
});
// Reactively control body scroll
$effect(() => {
if (typeof document !== 'undefined') {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
}
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
});
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
isClosing = false;
}
function closeMobileMenu() {
isClosing = true;
setTimeout(() => {
mobileMenuOpen = false;
isClosing = false;
}, 300);
}
function handleNavigation(path, options = {}) {
navigate(path, options);
currentPath = getPath();
closeMobileMenu();
}
function handleLanguageSwitch(lang) {
switchLanguageRoute(lang);
closeMobileMenu();
}
</script>
<style>
@keyframes expandFromButton {
from {
clip-path: circle(0px at calc(100% - 2rem) 2rem);
}
to {
clip-path: circle(150% at calc(100% - 2rem) 2rem);
}
}
@keyframes collapseToButton {
from {
clip-path: circle(150% at calc(100% - 2rem) 2rem);
}
to {
clip-path: circle(0px at calc(100% - 2rem) 2rem);
}
}
.menu-opening {
animation: expandFromButton 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.menu-closing {
animation: collapseToButton 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.mobile-menu-overlay {
height: 100vh;
max-height: 100vh;
}
@supports (height: 100svh) {
.mobile-menu-overlay {
height: 100svh;
max-height: 100svh;
}
}
@supports (height: 100dvh) {
.mobile-menu-overlay {
height: 100dvh;
max-height: 100dvh;
}
}
</style>
<header class="fixed inset-x-0 top-0 z-50 text-white">
<div class="border-b-2 border-orange-500 bg-gray-900">
<Container class="py-0">
{#if $text && Object.keys($text).length > 0}
<div class="flex h-(--navbar-offset) items-center gap-4">
<Button
onClick={() => handleNavigation("/")}
class="nav-logo-button border-transparent px-0 py-1 md:py-3"
>
<img src="/assets/logo.svg" alt="Lapikud logo" class="h-14 w-auto" />
</Button>
<div class="ml-auto flex w-full items-center justify-end md:w-auto">
<nav class="z-10 hidden items-center gap-1 justify-end text-lg md:flex">
<Dropdown
name={$text.about}
buttonClass={`${dropdownButtonClass(isInGroup(aboutRoutes))} ${isInGroup(aboutRoutes) ? "nav-active-parent" : ""}`}
panelClass={dropdownPanelClass()}
>
<Button
class={dropdownItemClass(isCurrent("/lapikutest") || isCurrent("/aboutus"))}
onClick={() => handleNavigation("/lapikutest")}
>
{$text.aboutPages.info}
</Button>
<Button
class={dropdownItemClass(isCurrent("/tudengile") || isCurrent("/student"))}
onClick={() => handleNavigation("/tudengile")}
>
<Bot />{$text.aboutPages.join}
</Button>
<Button
class={dropdownItemClass(isCurrent("/mentorid") || isCurrent("/mentors"))}
onClick={() => handleNavigation("/mentorid")}
>
<HandHeart />{$text.aboutPages.mentors}
</Button>
<Button
class={dropdownItemClass(isCurrent("/juhatus") || isCurrent("/management"))}
onClick={() => handleNavigation("/juhatus")}
>
<Lectern />{$text.aboutPages.board}
</Button>
</Dropdown>
<Dropdown
name={$text.events}
buttonClass={`${dropdownButtonClass(isInGroup(eventsRoutes))} ${isInGroup(eventsRoutes) ? "nav-active-parent" : ""}`}
panelClass={dropdownPanelClass()}
>
<Button
class={dropdownItemClass(isCurrent("/koolitused") || isCurrent("/workshops"))}
onClick={() => handleNavigation("/koolitused")}
>
<Presentation />{$text.eventsPages.workshops}
</Button>
<Button
class={dropdownItemClass(false)}
onClick={() => handleNavigation("https://tipilan.ee/", { external: true })}
>
<Gamepad2 />
<span class="flex w-full items-center justify-between gap-4">
{$text.eventsPages.tipilan}
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
<Button
class={dropdownItemClass(false)}
onClick={() => handleNavigation("https://asikarikas.ee/", { external: true })}
>
<Trophy />
<span class="flex w-full items-center justify-between gap-4">
ASI Karikas
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
<Button
class={dropdownItemClass(false)}
onClick={() => handleNavigation("https://remondikohvik.lapikud.ee/", { external: true })}
>
<Coffee />
<span class="flex w-full items-center justify-between gap-4">
{$text.eventsPages.repair}
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
</Dropdown>
<Button
class={navButtonClass(isCurrent("/helpdesk"))}
onClick={() => handleNavigation("/helpdesk")}
>
{$text.helpdesk}
</Button>
<Button
class={navButtonClass(isCurrent("/kontakt") || isCurrent("/contact"))}
onClick={() => handleNavigation("/kontakt")}
>
{$text.contact}
</Button>
<Button
class="ml-3 border-transparent bg-transparent px-3 py-1 text-sm text-white/75 hover:bg-white/5"
onClick={() => switchLanguageRoute($currentLang === "est" ? "en" : "est")}
>
{$currentLang === "est" ? "EN" : "EST"}
</Button>
</nav>
{#if !mobileMenuOpen}
<Button
onClick={toggleMobileMenu}
class="mobile-only ml-auto border-transparent bg-transparent p-2 text-white transition-colors hover:text-orange-500"
aria-label="Toggle navigation menu"
>
<Menu size={32} />
</Button>
{/if}
</div>
</div>
{/if}
</Container>
</div>
</header>
<!-- Full Screen Mobile Menu -->
{#if mobileMenuOpen}
<div
class="mobile-menu-overlay fixed inset-0 z-100 bg-gray-900 mobile-only overflow-y-auto"
data-animation
class:menu-opening={!isClosing}
class:menu-closing={isClosing}
>
<Stack class="min-h-full">
<div class="absolute top-6 right-4">
<Button
onClick={closeMobileMenu}
class="ml-auto p-2 transition-colors flex bg-transparent border-transparent"
aria-label="Close menu"
>
<X size={32} color="white" />
</Button>
</div>
<!-- Menu Content -->
<Stack gap="gap-5" class="flex-1 px-8 pt-24 pb-12 text-white text-xl">
<Button
onClick={() => handleNavigation("/")}
class="mb-3 border-transparent bg-transparent px-0 py-0 text-left"
>
<img src="/assets/logo.svg" alt="Lapikud Logo" class="h-12 w-auto" />
</Button>
<!-- About Section -->
<Stack gap="gap-3">
<h3 class="text-2xl font-bold text-orange-500">{$text.about}</h3>
<Stack gap="gap-2" class="pl-4">
<Button
onClick={() => handleNavigation("/lapikutest")}
class={`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent("/lapikutest") || isCurrent("/aboutus") ? "text-orange-500" : ""}`}
>
{$text.aboutPages.info}
</Button>
<Button
onClick={() => handleNavigation("/tudengile")}
class={`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent("/tudengile") || isCurrent("/student") ? "text-orange-500" : ""}`}
>
<Bot size={20} />
{$text.aboutPages.join}
</Button>
<Button
onClick={() => handleNavigation("/mentorid")}
class={`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent("/mentorid") || isCurrent("/mentors") ? "text-orange-500" : ""}`}
>
<HandHeart size={20} />
{$text.aboutPages.mentors}
</Button>
<Button
onClick={() => handleNavigation("/juhatus")}
class={`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent("/juhatus") || isCurrent("/management") ? "text-orange-500" : ""}`}
>
<Lectern size={20} />
{$text.aboutPages.board}
</Button>
</Stack>
</Stack>
<!-- Events Section -->
<Stack gap="gap-3" >
<h3 class="text-2xl font-bold text-orange-500">{$text.events}</h3>
<Stack gap="gap-2" class="pl-4">
<Button
onClick={() => handleNavigation("/koolitused")}
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent("/koolitused") || isCurrent("/workshops") ? "text-orange-500" : ""}"
>
<Presentation size={20} />
{$text.eventsPages.workshops}
</Button>
<Button
onClick={() => handleNavigation("https://tipilan.ee/", { external: true })}
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Gamepad2 size={20} />
<span class="flex w-full items-center justify-between gap-4">
{$text.eventsPages.tipilan}
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
<Button
onClick={() => handleNavigation("https://asikarikas.ee/", { external: true })}
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Trophy size={20} />
<span class="flex w-full items-center justify-between gap-4">
ASI Karikas
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
<Button
onClick={() => handleNavigation("https://remondikohvik.lapikud.ee/", { external: true })}
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Coffee size={20} />
<span class="flex w-full items-center justify-between gap-4">
{$text.eventsPages.repair}
<ExternalLink size={16} aria-hidden="true" />
</span>
</Button>
</Stack>
</Stack>
<!-- Direct Links -->
<Stack gap="gap-3">
<Button
onClick={() => handleNavigation("/helpdesk")}
class={`text-left text-2xl font-semibold transition-colors bg-transparent border-transparent ${isCurrent("/helpdesk") ? "text-orange-500" : ""}`}
>
{$text.helpdesk}
</Button>
<Button
onClick={() => handleNavigation("/kontakt")}
class={`text-left text-2xl font-semibold transition-colors bg-transparent border-transparent ${isCurrent("/kontakt") || isCurrent("/contact") ? "text-orange-500" : ""}`}
>
{$text.contact}
</Button>
</Stack>
<!-- Language Switcher -->
<Stack gap="gap-3">
<Button
class="w-full rounded-lg py-3 text-lg bg-orange-500 text-black border-transparent"
onClick={() => handleLanguageSwitch($currentLang === 'est' ? 'en' : 'est')}
>
{$currentLang === 'est' ? 'English' : 'Eesti'}
</Button>
</Stack>
</Stack>
</Stack>
</div>
{/if}

430
src/layout/Navbar.vue Normal file
View File

@@ -0,0 +1,430 @@
<script setup>
import {
Button,
Container,
Dropdown,
Stack,
} from "../components/index.js";
import { navigate, getPath, currentLang, text, switchLanguageRoute } from "../lib/index.js";
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
// Icon imports (Lucide)
import { Coffee } from "@lucide/vue";
import { Presentation } from "@lucide/vue";
import { Gamepad2 } from "@lucide/vue";
import { Lectern } from "@lucide/vue";
import { Bot } from "@lucide/vue";
import { HandHeart } from "@lucide/vue";
import { Trophy } from "@lucide/vue";
import { ExternalLink } from "@lucide/vue";
import { Menu } from "@lucide/vue";
import { X } from "@lucide/vue";
const mobileMenuOpen = ref(false);
const isClosing = ref(false);
const currentPath = ref(getPath());
let closeTimer;
const aboutRoutes = [
"/lapikutest",
"/aboutus",
"/tudengile",
"/student",
"/mentorid",
"/mentors",
"/juhatus",
"/management",
];
const eventsRoutes = ["/koolitused", "/workshops"];
function isCurrent(path) {
return currentPath.value === path;
}
function isInGroup(paths) {
return paths.includes(currentPath.value);
}
// combines styles for easier modifying
function navButtonClass(active = false) {
return active
? "border-transparent text-orange-500 hover:bg-white/5"
: "border-transparent text-white/75 hover:bg-white/5";
}
function dropdownButtonClass(active = false) {
return active
? "border-transparent text-orange-500 hover:bg-white/5"
: "border-transparent text-white/75 hover:bg-white/5";
}
function dropdownItemClass(active = false) {
return active
? "w-full justify-start gap-2 border-transparent text-orange-500"
: "w-full justify-start gap-2 border-transparent text-black hover:text-orange-500";
}
function dropdownPanelClass() {
return "nav-dropdown-panel min-w-52 border border-black/10 bg-white p-1 text-black";
}
// Update currentPath when navigation occurs
const syncPath = () => { currentPath.value = getPath(); };
onMounted(() => window.addEventListener('popstate', syncPath));
// Reactively control body scroll
watch(mobileMenuOpen, (open) => {
if (typeof document !== 'undefined') {
if (open) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
}
}, { immediate: true });
onBeforeUnmount(() => {
window.removeEventListener('popstate', syncPath);
clearTimeout(closeTimer);
if (typeof document !== 'undefined') {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
});
function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value;
isClosing.value = false;
}
function closeMobileMenu() {
isClosing.value = true;
closeTimer = setTimeout(() => {
mobileMenuOpen.value = false;
isClosing.value = false;
}, 300);
}
function handleNavigation(path, options = {}) {
navigate(path, options);
currentPath.value = getPath();
closeMobileMenu();
}
function handleLanguageSwitch(lang) {
switchLanguageRoute(lang);
closeMobileMenu();
}
</script>
<template>
<header class="fixed inset-x-0 top-0 z-50 text-white">
<div class="border-b-2 border-orange-500 bg-gray-900">
<Container class="py-0">
<template v-if="text && Object.keys(text).length > 0">
<div class="flex h-(--navbar-offset) items-center gap-4">
<Button
:onClick="() => handleNavigation('/')"
class="nav-logo-button border-transparent px-0 py-1 md:py-3"
>
<img src="/assets/logo.svg" alt="Lapikud logo" class="h-14 w-auto" />
</Button>
<div class="ml-auto flex w-full items-center justify-end md:w-auto">
<nav class="z-10 hidden items-center gap-1 justify-end text-lg md:flex">
<Dropdown
:name="text.about"
:buttonClass="`${dropdownButtonClass(isInGroup(aboutRoutes))} ${isInGroup(aboutRoutes) ? 'nav-active-parent' : ''}`"
:panelClass="dropdownPanelClass()"
>
<Button
:class="dropdownItemClass(isCurrent('/lapikutest') || isCurrent('/aboutus'))"
:onClick="() => handleNavigation('/lapikutest')"
>
{{ text.aboutPages.info }}
</Button>
<Button
:class="dropdownItemClass(isCurrent('/tudengile') || isCurrent('/student'))"
:onClick="() => handleNavigation('/tudengile')"
>
<Bot />{{ text.aboutPages.join }}
</Button>
<Button
:class="dropdownItemClass(isCurrent('/mentorid') || isCurrent('/mentors'))"
:onClick="() => handleNavigation('/mentorid')"
>
<HandHeart />{{ text.aboutPages.mentors }}
</Button>
<Button
:class="dropdownItemClass(isCurrent('/juhatus') || isCurrent('/management'))"
:onClick="() => handleNavigation('/juhatus')"
>
<Lectern />{{ text.aboutPages.board }}
</Button>
</Dropdown>
<Dropdown
:name="text.events"
:buttonClass="`${dropdownButtonClass(isInGroup(eventsRoutes))} ${isInGroup(eventsRoutes) ? 'nav-active-parent' : ''}`"
:panelClass="dropdownPanelClass()"
>
<Button
:class="dropdownItemClass(isCurrent('/koolitused') || isCurrent('/workshops'))"
:onClick="() => handleNavigation('/koolitused')"
>
<Presentation />{{ text.eventsPages.workshops }}
</Button>
<Button
:class="dropdownItemClass(false)"
:onClick="() => handleNavigation('https://tipilan.ee/', { external: true })"
>
<Gamepad2 />
<span class="flex w-full items-center justify-between gap-4">
{{ text.eventsPages.tipilan }}
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
<Button
:class="dropdownItemClass(false)"
:onClick="() => handleNavigation('https://asikarikas.ee/', { external: true })"
>
<Trophy />
<span class="flex w-full items-center justify-between gap-4">
ASI Karikas
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
<Button
:class="dropdownItemClass(false)"
:onClick="() => handleNavigation('https://remondikohvik.lapikud.ee/', { external: true })"
>
<Coffee />
<span class="flex w-full items-center justify-between gap-4">
{{ text.eventsPages.repair }}
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
</Dropdown>
<Button
:class="navButtonClass(isCurrent('/helpdesk'))"
:onClick="() => handleNavigation('/helpdesk')"
>
{{ text.helpdesk }}
</Button>
<Button
:class="navButtonClass(isCurrent('/kontakt') || isCurrent('/contact'))"
:onClick="() => handleNavigation('/kontakt')"
>
{{ text.contact }}
</Button>
<Button
class="ml-3 border-transparent bg-transparent px-3 py-1 text-sm text-white/75 hover:bg-white/5"
:onClick="() => switchLanguageRoute(currentLang === 'est' ? 'en' : 'est')"
>
{{ currentLang === "est" ? "EN" : "EST" }}
</Button>
</nav>
<template v-if="!mobileMenuOpen">
<Button
:onClick="toggleMobileMenu"
class="mobile-only ml-auto border-transparent bg-transparent p-2 text-white transition-colors hover:text-orange-500"
aria-label="Toggle navigation menu"
>
<Menu :size="32" />
</Button>
</template>
</div>
</div>
</template>
</Container>
</div>
</header>
<!-- Full Screen Mobile Menu -->
<template v-if="mobileMenuOpen">
<div
class="mobile-menu-overlay fixed inset-0 z-100 bg-gray-900 mobile-only overflow-y-auto"
data-animation
:class="{ 'menu-opening': !isClosing, 'menu-closing': isClosing }"
>
<Stack class="min-h-full">
<div class="absolute top-6 right-4">
<Button
:onClick="closeMobileMenu"
class="ml-auto p-2 transition-colors flex bg-transparent border-transparent"
aria-label="Close menu"
>
<X :size="32" color="white" />
</Button>
</div>
<!-- Menu Content -->
<Stack gap="gap-5" class="flex-1 px-8 pt-24 pb-12 text-white text-xl">
<Button
:onClick="() => handleNavigation('/')"
class="mb-3 border-transparent bg-transparent px-0 py-0 text-left"
>
<img src="/assets/logo.svg" alt="Lapikud Logo" class="h-12 w-auto" />
</Button>
<!-- About Section -->
<Stack gap="gap-3">
<h3 class="text-2xl font-bold text-orange-500">{{ text.about }}</h3>
<Stack gap="gap-2" class="pl-4">
<Button
:onClick="() => handleNavigation('/lapikutest')"
:class="`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent('/lapikutest') || isCurrent('/aboutus') ? 'text-orange-500' : ''}`"
>
{{ text.aboutPages.info }}
</Button>
<Button
:onClick="() => handleNavigation('/tudengile')"
:class="`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent('/tudengile') || isCurrent('/student') ? 'text-orange-500' : ''}`"
>
<Bot :size="20" />
{{ text.aboutPages.join }}
</Button>
<Button
:onClick="() => handleNavigation('/mentorid')"
:class="`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent('/mentorid') || isCurrent('/mentors') ? 'text-orange-500' : ''}`"
>
<HandHeart :size="20" />
{{ text.aboutPages.mentors }}
</Button>
<Button
:onClick="() => handleNavigation('/juhatus')"
:class="`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent('/juhatus') || isCurrent('/management') ? 'text-orange-500' : ''}`"
>
<Lectern :size="20" />
{{ text.aboutPages.board }}
</Button>
</Stack>
</Stack>
<!-- Events Section -->
<Stack gap="gap-3" >
<h3 class="text-2xl font-bold text-orange-500">{{ text.events }}</h3>
<Stack gap="gap-2" class="pl-4">
<Button
:onClick="() => handleNavigation('/koolitused')"
:class="`text-left transition-colors flex items-center gap-2 bg-transparent border-transparent ${isCurrent('/koolitused') || isCurrent('/workshops') ? 'text-orange-500' : ''}`"
>
<Presentation :size="20" />
{{ text.eventsPages.workshops }}
</Button>
<Button
:onClick="() => handleNavigation('https://tipilan.ee/', { external: true })"
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Gamepad2 :size="20" />
<span class="flex w-full items-center justify-between gap-4">
{{ text.eventsPages.tipilan }}
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
<Button
:onClick="() => handleNavigation('https://asikarikas.ee/', { external: true })"
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Trophy :size="20" />
<span class="flex w-full items-center justify-between gap-4">
ASI Karikas
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
<Button
:onClick="() => handleNavigation('https://remondikohvik.lapikud.ee/', { external: true })"
class="text-left transition-colors flex items-center gap-2 bg-transparent border-transparent"
>
<Coffee :size="20" />
<span class="flex w-full items-center justify-between gap-4">
{{ text.eventsPages.repair }}
<ExternalLink :size="16" aria-hidden="true" />
</span>
</Button>
</Stack>
</Stack>
<!-- Direct Links -->
<Stack gap="gap-3">
<Button
:onClick="() => handleNavigation('/helpdesk')"
:class="`text-left text-2xl font-semibold transition-colors bg-transparent border-transparent ${isCurrent('/helpdesk') ? 'text-orange-500' : ''}`"
>
{{ text.helpdesk }}
</Button>
<Button
:onClick="() => handleNavigation('/kontakt')"
:class="`text-left text-2xl font-semibold transition-colors bg-transparent border-transparent ${isCurrent('/kontakt') || isCurrent('/contact') ? 'text-orange-500' : ''}`"
>
{{ text.contact }}
</Button>
</Stack>
<!-- Language Switcher -->
<Stack gap="gap-3">
<Button
class="w-full rounded-lg py-3 text-lg bg-orange-500 text-black border-transparent"
:onClick="() => handleLanguageSwitch(currentLang === 'est' ? 'en' : 'est')"
>
{{ currentLang === 'est' ? 'English' : 'Eesti' }}
</Button>
</Stack>
</Stack>
</Stack>
</div>
</template>
</template>
<style>
@keyframes expandFromButton {
from {
clip-path: circle(0px at calc(100% - 2rem) 2rem);
}
to {
clip-path: circle(150% at calc(100% - 2rem) 2rem);
}
}
@keyframes collapseToButton {
from {
clip-path: circle(150% at calc(100% - 2rem) 2rem);
}
to {
clip-path: circle(0px at calc(100% - 2rem) 2rem);
}
}
.menu-opening {
animation: expandFromButton 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.menu-closing {
animation: collapseToButton 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.mobile-menu-overlay {
height: 100vh;
max-height: 100vh;
}
@supports (height: 100svh) {
.mobile-menu-overlay {
height: 100svh;
max-height: 100svh;
}
}
@supports (height: 100dvh) {
.mobile-menu-overlay {
height: 100dvh;
max-height: 100dvh;
}
}
</style>

View File

@@ -1,103 +1,65 @@
import { writable } from 'svelte/store'; import { onScopeDispose, ref, watch } from 'vue';
import { getLanguageFromRoute } from '../routes'; import { getLanguageFromRoute } from '../routes';
const localeModules = import.meta.glob('./locales/*/*.json'); const localeModules = import.meta.glob('./locales/*/*.json');
// Get initial language from URL or localStorage
function getInitialLanguage() { function getInitialLanguage() {
if (typeof window === 'undefined') return 'est'; if (typeof window === 'undefined') return 'est';
const path = window.location.pathname; const langFromRoute = getLanguageFromRoute(window.location.pathname);
const langFromRoute = getLanguageFromRoute(path);
// If we detected a language from route, use it and save it
if (langFromRoute) { if (langFromRoute) {
localStorage.setItem('language', langFromRoute); localStorage.setItem('language', langFromRoute);
return langFromRoute; return langFromRoute;
} }
// Otherwise use saved language or default to 'est'
return localStorage.getItem('language') || 'est'; return localStorage.getItem('language') || 'est';
} }
const initialLang = getInitialLanguage(); export const currentLang = ref(getInitialLanguage());
export const text = ref({});
export const currentLang = writable(initialLang);
// Store for translations
export const text = writable({});
async function loadTranslations(lang) { async function loadTranslations(lang) {
try { try {
const translations = {}; const translations = {};
const matchingModules = Object.entries(localeModules).filter(([path]) =>
// Dynamically import all translation files for the specific language path.includes(`/locales/${lang}/`),
// Vite will code-split these and only bundle the ones actually used );
for (const path in localeModules) { const modules = await Promise.all(matchingModules.map(([, loader]) => loader()));
if (path.includes(`/locales/${lang}/`) || path.includes(`\\locales\\${lang}\\`)) { modules.forEach((module) => Object.assign(translations, module.default || module));
const module = await localeModules[path](); text.value = translations;
Object.assign(translations, module.default || module);
}
}
text.set(translations);
} catch (error) { } catch (error) {
console.error(`Failed to load translations for ${lang}:`, error); console.error(`Failed to load translations for ${lang}:`, error);
text.value = {};
} }
} }
loadTranslations(initialLang); watch(currentLang, loadTranslations, { immediate: true });
/** export function usePageText(pageName) {
* Create a translation store that only loads one page file per language. const pageText = ref({});
* Example: createPageTextStore('Home') -> ./locales/est/Home.json or ./locales/en/Home.json const stop = watch(currentLang, async (lang) => {
*/
export function createPageTextStore(pageName) {
const pageText = writable({});
async function loadPageTranslations(lang) {
try { try {
const filePath = `./locales/${lang}/${pageName}.json`; const loader = localeModules[`./locales/${lang}/${pageName}.json`];
const loader = localeModules[filePath]; const module = loader ? await loader() : null;
pageText.value = module?.default || module || {};
if (!loader) {
pageText.set({});
return;
}
const module = await loader();
pageText.set(module.default || module);
} catch (error) { } catch (error) {
console.error(`Failed to load ${pageName} translations for ${lang}:`, error); console.error(`Failed to load ${pageName} translations for ${lang}:`, error);
pageText.set({}); pageText.value = {};
} }
} }, { immediate: true });
const unsubscribe = currentLang.subscribe((lang) => { onScopeDispose(stop);
loadPageTranslations(lang); return pageText;
});
return {
subscribe: pageText.subscribe,
destroy: unsubscribe,
};
} }
export function switchLang(lang) { export function switchLang(lang) {
currentLang.set(lang); currentLang.value = lang;
loadTranslations(lang); if (typeof window !== 'undefined') localStorage.setItem('language', lang);
if (typeof window !== 'undefined') {
localStorage.setItem('language', lang);
}
} }
// Update language when route changes
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
const path = window.location.pathname; const lang = getLanguageFromRoute(window.location.pathname);
const lang = getLanguageFromRoute(path); if (lang) switchLang(lang);
if (lang) {
switchLang(lang);
}
}); });
} }

View File

@@ -1,5 +1,5 @@
export { navigate, goBack, reload, getPath, getQuery, switchLanguageRoute } from './router/router.js'; export { navigate, goBack, reload, getPath, getQuery, switchLanguageRoute } from './router/router.js';
export { default as Router } from './router/Router.svelte'; export { default as Router } from './router/Router.vue';
export { currentLang, text, switchLang, createPageTextStore } from './i18n.js'; export { currentLang, text, switchLang, usePageText } from './i18n.js';
export { getLangText } from './langHelpers.js'; export { getLangText } from './langHelpers.js';

View File

@@ -1,73 +0,0 @@
<!-- DO NOT TOUCH THIS FILE AT ANY COST -->
<!-- unless the router is broken 👉👈 -->
<script>
export function routeTo(path) {
window.history.pushState({}, "", path);
window.scrollTo(0, 0);
window.dispatchEvent(new PopStateEvent("popstate"));
}
let { routes } = $props();
let currentPath = $state(window.location.pathname);
let CurrentComponent = $state(null);
async function resolveCurrentComponent(path) {
const routeEntry = routes[path] || routes["/"];
if (typeof routeEntry === "function") {
try {
const module = await routeEntry();
CurrentComponent = module?.default || null;
} catch {
CurrentComponent = null;
}
return;
}
CurrentComponent = routeEntry || null;
}
function navigate(path) {
window.history.pushState({}, "", path);
window.scrollTo(0, 0);
currentPath = path;
}
// Handle back/forward buttons
$effect(() => {
const handlePopState = () => {
currentPath = window.location.pathname;
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
});
// Intercept link clicks
$effect(() => {
const handleClick = (e) => {
if (
e.target.tagName === "A" &&
e.target.getAttribute("href")?.startsWith("/")
) {
e.preventDefault();
navigate(e.target.getAttribute("href"));
}
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
});
$effect(() => {
resolveCurrentComponent(currentPath);
});
</script>
{#if CurrentComponent}
<CurrentComponent />
{/if}

53
src/lib/router/Router.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup>
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
const props = defineProps({ routes: { type: Object, required: true } });
const currentPath = shallowRef(window.location.pathname);
const CurrentComponent = shallowRef(null);
async function resolveCurrentComponent(path) {
const routeEntry = props.routes[path] || props.routes['/'];
if (typeof routeEntry !== 'function') {
CurrentComponent.value = routeEntry || null;
return;
}
try {
const module = await routeEntry();
CurrentComponent.value = module?.default || null;
} catch {
CurrentComponent.value = null;
}
}
function navigate(path) {
window.history.pushState({}, '', path);
window.scrollTo(0, 0);
currentPath.value = path;
}
const handlePopState = () => {
currentPath.value = window.location.pathname;
};
const handleClick = (event) => {
if (event.target.tagName === 'A' && event.target.getAttribute('href')?.startsWith('/')) {
event.preventDefault();
navigate(event.target.getAttribute('href'));
}
};
onMounted(() => {
window.addEventListener('popstate', handlePopState);
window.addEventListener('click', handleClick);
});
onBeforeUnmount(() => {
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('click', handleClick);
});
watch(currentPath, resolveCurrentComponent, { immediate: true });
</script>
<template>
<component :is="CurrentComponent" v-if="CurrentComponent" />
</template>

View File

@@ -1,9 +1,8 @@
import { mount } from 'svelte' import { createApp } from 'vue'
import './app.css' import './app.css'
import App from './App.svelte' import App from './App.vue'
const app = mount(App, { const app = createApp(App)
target: document.getElementById('app'), app.mount('#app')
})
export default app export default app

View File

@@ -1,21 +0,0 @@
<script>
import {
Section,
} from "$components";
import { onDestroy } from "svelte";
import { createPageTextStore } from "$lib";
const text = createPageTextStore("AboutUs");
onDestroy(() => {
text.destroy();
});
</script>
<div class="safe-area-navbar">
<Section>
<p>In development...</p>
<p>Põhikiri siia?</p>
</Section>
</div>

19
src/routes/AboutUs.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup>
import {
Section,
} from "../components/index.js";
import { usePageText } from "../lib/index.js";
const text = usePageText("AboutUs");
</script>
<template>
<div class="safe-area-navbar">
<Section>
<p>In development...</p>
<p>Põhikiri siia?</p>
</Section>
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script>
import { Section, Center } from "$components";
import { onMount, onDestroy } from "svelte";
import { createPageTextStore } from "$lib";
let sectionElement;
const text = createPageTextStore("Calendar");
onMount(() => {
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
onDestroy(() => {
text.destroy();
});
</script>
<div class="safe-area-navbar" bind:this={sectionElement}>
<Section >
<Center dir="col" class="w-full">
<div class="w-full max-w-4xl">
<iframe
style="border: 0;"
src="https://www.google.com/calendar/embed?src=lapikud%40gmail.com&ctz=Europe/Tallinn&showTitle=0&showPrint=0&showCalendars=0&showTz=0&wkst=2"
title={$text.iframeTitle || "Lapikud Calendar"}
class="w-full"
height="600"
frameborder="0"
scrolling="no"
></iframe>
</div>
</Center>
</Section>
</div>

32
src/routes/Calendar.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup>
import { Section, Center } from "../components/index.js";
import { onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
const sectionElement = ref(null);
const text = usePageText("Calendar");
onMounted(() => {
sectionElement.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
</script>
<template>
<div class="safe-area-navbar" ref="sectionElement">
<Section >
<Center dir="col" class="w-full">
<div class="w-full max-w-4xl">
<iframe
style="border: 0;"
src="https://www.google.com/calendar/embed?src=lapikud%40gmail.com&ctz=Europe/Tallinn&showTitle=0&showPrint=0&showCalendars=0&showTz=0&wkst=2"
:title="text.iframeTitle || 'Lapikud Calendar'"
class="w-full"
height="600"
frameborder="0"
scrolling="no"
></iframe>
</div>
</Center>
</Section>
</div>
</template>

View File

@@ -1,21 +0,0 @@
<script>
import {
Section,
Grid,
Center,
} from "$components";
import { onDestroy } from "svelte";
import { createPageTextStore } from "$lib";
const text = createPageTextStore("Contact");
onDestroy(() => {
text.destroy();
});
</script>
<div class="safe-area-navbar">
<Section>
<p>In development...</p>
</Section>
</div>

19
src/routes/Contact.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup>
import {
Section,
Grid,
Center,
} from "../components/index.js";
import { usePageText } from "../lib/index.js";
const text = usePageText("Contact");
</script>
<template>
<div class="safe-area-navbar">
<Section>
<p>In development...</p>
</Section>
</div>
</template>

View File

@@ -1,205 +0,0 @@
<script>
import {
Section,
Grid,
} from "$components";
import { onMount, onDestroy } from "svelte";
import { createPageTextStore } from "$lib";
import { getHeroImageFallback, getHeroImageSrcSet, getRootAssetPath } from "$lib/imageHelpers.js";
import yaml from 'js-yaml';
import { MapLibre, Marker } from "svelte-maplibre";
import MapPin from "lucide-svelte/icons/map-pin";
import BusFront from "lucide-svelte/icons/bus-front";
import { navigate } from "$lib/router/router.js";
// Images
const helpdeskbg = getHeroImageFallback("helpdesk-page-images", "helpdesk_bg");
const helpdeskbgWebpSet = getHeroImageSrcSet("helpdesk-page-images", "helpdesk_bg");
const helpdesk = getRootAssetPath("helpdesk-page-images", "helpdesk-on-black.png");
// Coordinates for Akadeemia tee 5, 12616 Tallinn [longitude, latitude]
const center = [24.66887400515207, 59.396427975093935];
// Coordinates for Keemia bus stop [longitude, latitude]
const keemiaBusStop = [24.668325396145953, 59.397074517662276];
// Coordinates for Tehnikaülikool bus stop [longitude, latitude]
const tehnikaBusStop = [24.67328945396854, 59.39508874042947];
// Google Maps directions URL
const getDirectionsUrl = (lng, lat, loc) => {
if (loc == "bus"){
return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&travelmode=transit`;
}
return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
};
// Navigate to directions using router
const openDirections = (lng, lat, loc) => {
navigate(getDirectionsUrl(lng, lat, loc), { external: true });
};
// Load pricing data
let pricingData = { services: [] };
const text = createPageTextStore("Helpdesk");
onMount(async () => {
const response = await fetch('/_data/hinnakiri.yml');
const yamlText = await response.text();
pricingData = yaml.load(yamlText);
});
onDestroy(() => {
text.destroy();
});
</script>
<div class="safe-area-navbar">
<!-- Hero Section -->
<Section class="relative bg-gray-900 min-h-[42vh] overflow-hidden" padding="none" fullWidth={true}>
<div class="absolute inset-0 left-1/2 w-screen -translate-x-1/2 h-[42vh]" aria-hidden="true">
<picture>
<source srcset={helpdeskbgWebpSet} type="image/webp" />
<img
class="h-full w-full object-cover object-left opacity-50 blur-[1.5px]"
src={helpdeskbg}
alt="Helpdesk"
/>
</picture>
</div>
<div class="relative flex justify-center items-center flex-row w-full h-[42vh] z-10">
<img src={helpdesk} alt={$text["hero"]?.title || "Helpdesk logo"} />
</div>
</Section>
<!-- Main Content -->
<Section>
<Grid columns={2} tabletColumns={1} mobileColumns={1} gap="gap-6">
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{$text["whatIs"]?.title || ""}</h2>
<p class="text-lg">
{$text["whatIs"]?.description || ""}
</p>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{$text["services"]?.title || ""}</h2>
<ul class="text-lg list-disc pl-5">
{#each $text["services"]?.list || [] as service}
<li>{service}</li>
{/each}
</ul>
</div>
<!-- Pricing Table -->
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{$text["pricing"]?.title || ""}</h2>
<div class="overflow-hidden rounded-lg border border-gray-200 shadow-sm">
<table class="w-full text-lg">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{$text["pricing"]?.tableHeaders?.service || ""}
</th>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{$text["pricing"]?.tableHeaders?.quantity || ""}
</th>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{$text["pricing"]?.tableHeaders?.price || ""}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each pricingData.services as service}
<tr class="hover:bg-gray-50 transition-colors duration-200">
<td class="px-3 py-4 text-gray-900">{service.name}</td>
<td class="px-3 py-4 text-gray-600">{service.quantity}</td>
<td class="px-3 py-4 font-medium" class:text-green-600={service.price === "Tasuta"} class:text-gray-900={service.price !== "Tasuta"}>
{service.price}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="pt-4 text-lg font-bold">
{@html $text["pricing"]?.note || ""}
</p>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">
{@html $text["hours"]?.title || ""}
<br />
{@html $text["hours"]?.schedule || ""}
</h2>
<p class="text-lg font-light opacity-70 pb-16">
{$text["hours"]?.note || ""}
</p>
<h2 class="text-4xl font-light pb-4">{$text["location"]?.title || ""}</h2>
<div class="w-full h-96 rounded-lg overflow-hidden">
<MapLibre
style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
class="relative w-full h-full"
{center}
zoom={15}
interactive={false}
attributionControl={false}
>
<!-- Main location -->
<Marker lngLat={center}>
<div
role="button"
tabindex="0"
onclick={() => openDirections(center[0], center[1])}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && openDirections(center[0], center[1])}
class="flex flex-col items-center hover:scale-110 transition-transform cursor-pointer"
title="Get directions to Akadeemia tee 5"
>
<span class="text-xs font-semibold text-white bg-orange-500 px-2 py-0.5 rounded shadow-md mb-1 whitespace-nowrap">Akadeemia tee 5</span>
<MapPin class="w-7 h-7 text-orange-500 drop-shadow-lg" />
</div>
</Marker>
<!-- Keemia bus stop -->
<Marker lngLat={keemiaBusStop}>
<div
role="button"
tabindex="0"
onclick={() => openDirections(keemiaBusStop[0], keemiaBusStop[1], "bus")}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && openDirections(keemiaBusStop[0], keemiaBusStop[1], "bus")}
class="flex flex-col items-center hover:scale-110 transition-transform cursor-pointer"
title="Get directions to Keemia bus stop"
>
<span class="text-xs font-semibold text-gray-900 bg-white px-2 py-0.5 rounded shadow-md mb-1">Keemia</span>
<BusFront class="w-7 h-7 text-orange-500 drop-shadow-lg" />
</div>
</Marker>
<!-- Tehnikaülikool bus stop -->
<Marker lngLat={tehnikaBusStop}>
<div
role="button"
tabindex="0"
onclick={() => openDirections(tehnikaBusStop[0], tehnikaBusStop[1], "bus")}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && openDirections(tehnikaBusStop[0], tehnikaBusStop[1], "bus")}
class="flex flex-col items-center hover:scale-110 transition-transform cursor-pointer"
title="Get directions to Tehnikaülikool bus stop"
>
<span class="text-xs font-semibold text-gray-900 bg-white px-2 py-0.5 rounded shadow-md mb-1">Tehnikaülikool</span>
<BusFront class="w-7 h-7 text-orange-500 drop-shadow-lg" />
</div>
</Marker>
</MapLibre>
</div>
<p class="pb-4 text-lg">
{@html $text["location"]?.busInfo || ""}
</p>
<p class="text-lg font-bold">
{$text["location"]?.address || ""}
</p>
</div>
</Grid>
</Section>
</div>

202
src/routes/Helpdesk.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup>
import {
Section,
Grid,
} from "../components/index.js";
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
import { getHeroImageFallback, getHeroImageSrcSet, getRootAssetPath } from "../lib/imageHelpers.js";
import yaml from 'js-yaml';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { navigate } from "../lib/router/router.js";
// Images
const helpdeskbg = getHeroImageFallback("helpdesk-page-images", "helpdesk_bg");
const helpdeskbgWebpSet = getHeroImageSrcSet("helpdesk-page-images", "helpdesk_bg");
const helpdesk = getRootAssetPath("helpdesk-page-images", "helpdesk-on-black.png");
// Coordinates for Akadeemia tee 5, 12616 Tallinn [longitude, latitude]
const center = [24.66887400515207, 59.396427975093935];
// Coordinates for Keemia bus stop [longitude, latitude]
const keemiaBusStop = [24.668325396145953, 59.397074517662276];
// Coordinates for Tehnikaülikool bus stop [longitude, latitude]
const tehnikaBusStop = [24.67328945396854, 59.39508874042947];
// Google Maps directions URL
const getDirectionsUrl = (lng, lat, loc) => {
if (loc == "bus"){
return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&travelmode=transit`;
}
return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
};
// Navigate to directions using router
const openDirections = (lng, lat, loc) => {
navigate(getDirectionsUrl(lng, lat, loc), { external: true });
};
// Load pricing data
const pricingData = ref({ services: [] });
const mapElement = ref(null);
let map;
const text = usePageText("Helpdesk");
onMounted(async () => {
const response = await fetch('/_data/hinnakiri.yml');
const yamlText = await response.text();
pricingData.value = yaml.load(yamlText) || { services: [] };
map = new maplibregl.Map({
container: mapElement.value,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center,
zoom: 15,
interactive: false,
attributionControl: false,
});
const locations = [
{ coordinates: center, label: 'Akadeemia tee 5', type: 'location' },
{ coordinates: keemiaBusStop, label: 'Keemia', type: 'bus' },
{ coordinates: tehnikaBusStop, label: 'Tehnikaülikool', type: 'bus' },
];
locations.forEach(({ coordinates, label, type }) => {
const marker = document.createElement('button');
marker.type = 'button';
marker.className = `helpdesk-marker helpdesk-marker--${type}`;
marker.setAttribute('aria-label', `Get directions to ${label}`);
marker.innerHTML = `<span>${label}</span><b aria-hidden="true">${type === 'bus' ? '●' : '◆'}</b>`;
marker.addEventListener('click', () => openDirections(coordinates[0], coordinates[1], type));
new maplibregl.Marker({ element: marker }).setLngLat(coordinates).addTo(map);
});
});
onBeforeUnmount(() => map?.remove());
</script>
<template>
<div class="safe-area-navbar">
<!-- Hero Section -->
<Section class="relative bg-gray-900 min-h-[42vh] overflow-hidden" padding="none" :fullWidth="true">
<div class="absolute inset-0 left-1/2 w-screen -translate-x-1/2 h-[42vh]" aria-hidden="true">
<picture>
<source :srcset="helpdeskbgWebpSet" type="image/webp" />
<img
class="h-full w-full object-cover object-left opacity-50 blur-[1.5px]"
:src="helpdeskbg"
alt="Helpdesk"
/>
</picture>
</div>
<div class="relative flex justify-center items-center flex-row w-full h-[42vh] z-10">
<img :src="helpdesk" :alt="text['hero']?.title || 'Helpdesk logo'" />
</div>
</Section>
<!-- Main Content -->
<Section>
<Grid :columns="2" :tabletColumns="1" :mobileColumns="1" gap="gap-6">
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{{ text.whatIs?.title || '' }}</h2>
<p class="text-lg">
{{ text.whatIs?.description || '' }}
</p>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{{ text.services?.title || '' }}</h2>
<ul class="text-lg list-disc pl-5">
<template v-for="service in text.services?.list || []">
<li>{{ service }}</li>
</template>
</ul>
</div>
<!-- Pricing Table -->
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">{{ text.pricing?.title || '' }}</h2>
<div class="overflow-hidden rounded-lg border border-gray-200 shadow-sm">
<table class="w-full text-lg">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{{ text.pricing?.tableHeaders?.service || '' }}
</th>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{{ text.pricing?.tableHeaders?.quantity || '' }}
</th>
<th class="px-3 py-4 text-left font-semibold text-gray-900 border-b border-gray-200">
{{ text.pricing?.tableHeaders?.price || '' }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template v-for="service in pricingData.services">
<tr class="hover:bg-gray-50 transition-colors duration-200">
<td class="px-3 py-4 text-gray-900">{{ service.name }}</td>
<td class="px-3 py-4 text-gray-600">{{ service.quantity }}</td>
<td class="px-3 py-4 font-medium" :class="service.price === 'Tasuta' ? 'text-green-600' : 'text-gray-900'">
{{ service.price }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="pt-4 text-lg font-bold">
<span v-html="text.pricing?.note || ''"></span>
</p>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">
<span v-html="text.hours?.title || ''"></span>
<br />
<span v-html="text.hours?.schedule || ''"></span>
</h2>
<p class="text-lg font-light opacity-70 pb-16">
{{ text.hours?.note || '' }}
</p>
<h2 class="text-4xl font-light pb-4">{{ text.location?.title || '' }}</h2>
<div ref="mapElement" class="relative w-full h-96 rounded-lg overflow-hidden"></div>
<p class="pb-4 text-lg">
<span v-html="text.location?.busInfo || ''"></span>
</p>
<p class="text-lg font-bold">
{{ text.location?.address || '' }}
</p>
</div>
</Grid>
</Section>
</div>
</template>
<style>
.helpdesk-marker {
display: flex;
cursor: pointer;
flex-direction: column;
align-items: center;
border: 0;
background: transparent;
transition: transform 150ms ease;
}
.helpdesk-marker:hover { transform: scale(1.1); }
.helpdesk-marker span {
margin-bottom: 0.25rem;
border-radius: 0.25rem;
background: white;
padding: 0.125rem 0.5rem;
color: #111827;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
}
.helpdesk-marker--location span { background: var(--orange); color: white; }
.helpdesk-marker b { color: var(--orange); font-size: 1.5rem; line-height: 1; }
</style>

View File

@@ -1,26 +1,22 @@
<script> <script setup>
import {Section, Stack, Grid, Center, Image, Svg, Button} from "$components"; import {Section, Stack, Grid, Center, Image, Svg, Button} from "../components/index.js";
import { navigate } from "$lib/router/router.js"; import { navigate } from "../lib/router/router.js";
import { createPageTextStore, currentLang } from "$lib"; import { usePageText, currentLang } from "../lib/index.js";
import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "$lib/imageHelpers.js"; import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { onMount, onDestroy } from "svelte"; import { onMounted, ref } from 'vue';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import ArrowRight from "lucide-svelte/icons/arrow-right"; import { ArrowRight } from "@lucide/vue";
import ArrowLeft from "lucide-svelte/icons/arrow-left"; import { ArrowLeft } from "@lucide/vue";
// Partners data // Partners data
let partners = []; const partners = ref([]);
const text = createPageTextStore("Home"); const text = usePageText("Home");
onMount(async () => { onMounted(async () => {
const response = await fetch('/_data/partners.yml'); const response = await fetch('/_data/partners.yml');
const yamlText = await response.text(); const yamlText = await response.text();
partners = yaml.load(yamlText); partners.value = yaml.load(yamlText) || [];
});
onDestroy(() => {
text.destroy();
}); });
const partnerLogoExtByName = { const partnerLogoExtByName = {
@@ -37,15 +33,16 @@
} }
</script> </script>
<template>
<!-- Hero Section --> <!-- Hero Section -->
<Section class="relative min-h-screen overflow-hidden" padding="none" bg="bg-gray-900" id="hero"> <Section class="relative min-h-screen overflow-hidden" padding="none" bg="bg-gray-900" id="hero">
<div class="relative min-h-screen flex items-center"> <div class="relative min-h-screen flex items-center">
<div class="absolute inset-0 left-1/2 w-screen -translate-x-1/2" aria-hidden="true"> <div class="absolute inset-0 left-1/2 w-screen -translate-x-1/2" aria-hidden="true">
<Image <Image
webpSrc={getHeroImagePath("home-page-images", "hero")} :webpSrc="getHeroImagePath('home-page-images', 'hero')"
src={getHeroImageFallback("home-page-images", "hero")} :src="getHeroImageFallback('home-page-images', 'hero')"
webpSrcSet={getHeroImageSrcSet("home-page-images", "hero")} :webpSrcSet="getHeroImageSrcSet('home-page-images', 'hero')"
alt={$text["hero"]?.imageAlt || "illustrative hero image"} :alt="text['hero']?.imageAlt || 'illustrative hero image'"
objectFit="cover" objectFit="cover"
class="w-full h-full" class="w-full h-full"
pictureClass="block w-full h-full" pictureClass="block w-full h-full"
@@ -73,13 +70,13 @@
<div class="flex w-full max-w-115 flex-col justify-end"> <div class="flex w-full max-w-115 flex-col justify-end">
<div class="flex flex-col items-start gap-5"> <div class="flex flex-col items-start gap-5">
<p class="w-full max-w-110 text-left text-lg leading-[165%] text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)] md:max-w-125 md:text-xl"> <p class="w-full max-w-110 text-left text-lg leading-[165%] text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)] md:max-w-125 md:text-xl">
{$text["hero"]?.description || "MTÜ Lapikud on Tallinna Tehnikaülikooli Tarkvaraarendusklubi, mis ühendab endisi ja praegusi IT huvilisi tudengeid."} {{ text.hero?.description || "MTÜ Lapikud on Tallinna Tehnikaülikooli Tarkvaraarendusklubi, mis ühendab endisi ja praegusi IT huvilisi tudengeid." }}
</p> </p>
<Button <Button
class="rounded-lg border-orange-500 bg-orange-500 px-3 py-2 text-gray-900 transition-colors hover:border-orange-300 hover:bg-orange-300" class="rounded-lg border-orange-500 bg-orange-500 px-3 py-2 text-gray-900 transition-colors hover:border-orange-300 hover:bg-orange-300"
onClick={() => navigate("/tudengile")} :onClick="() => navigate('/tudengile')"
> >
{$text["hero"]?.joinButton || "Liitu LAPikutega!"} {{ text.hero?.joinButton || "Liitu LAPikutega!" }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -100,50 +97,50 @@
/> />
</div> </div>
<Grid columns={2} largeColumns={3} mobileColumns={1} class="relative z-10 items-start mt-12 md:mt-20" gap="gap-12 md:gap-16 lg:gap-24"> <Grid :columns="2" :largeColumns="3" :mobileColumns="1" class="relative z-10 items-start mt-12 md:mt-20" gap="gap-12 md:gap-16 lg:gap-24">
<Center dir="col" class="w-full max-w-sm justify-start mx-auto"> <Center dir="col" class="w-full max-w-sm justify-start mx-auto">
<div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden"> <div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden">
<Image <Image
webpSrc={getOptimisedImagePath("home-page-images", "student_temp", "webp")} :webpSrc="getOptimisedImagePath('home-page-images', 'student_temp', 'webp')"
src={getOptimisedImageFallback("home-page-images", "student_temp")} :src="getOptimisedImageFallback('home-page-images', 'student_temp')"
alt={$text["services"]?.student?.imageAlt || "Tudengile Pilt"} :alt="text['services']?.student?.imageAlt || 'Tudengile Pilt'"
objectFit="cover" objectFit="cover"
class="w-full h-full rounded-full object-[center_35%]" class="w-full h-full rounded-full object-[center_35%]"
pictureClass="block w-full h-full" pictureClass="block w-full h-full"
/> />
</div> </div>
<h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{$text["services"]?.student?.title || "Tudengile"}</h3> <h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{{ text.services?.student?.title || "Tudengile" }}</h3>
<p class="w-full max-w-[26ch] text-center text-base">{$text["services"]?.student?.description || "Tule arenda oma oskuseid ja saa ägedaid sõpru! Omanda praktilist kogemust reaalsetest projektidest."}</p> <p class="w-full max-w-[26ch] text-center text-base">{{ text.services?.student?.description || "Tule arenda oma oskuseid ja saa ägedaid sõpru! Omanda praktilist kogemust reaalsetest projektidest." }}</p>
</Center> </Center>
<Center dir="col" class="w-full max-w-sm justify-start mx-auto"> <Center dir="col" class="w-full max-w-sm justify-start mx-auto">
<div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden"> <div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden">
<Image <Image
webpSrc={getOptimisedImagePath("home-page-images", "helpdesk", "webp")} :webpSrc="getOptimisedImagePath('home-page-images', 'helpdesk', 'webp')"
src={getOptimisedImageFallback("home-page-images", "helpdesk")} :src="getOptimisedImageFallback('home-page-images', 'helpdesk')"
alt={$text["services"]?.helpdesk?.imageAlt || "Helpdesk Pilt"} :alt="text['services']?.helpdesk?.imageAlt || 'Helpdesk Pilt'"
objectFit="cover" objectFit="cover"
class="w-full h-full rounded-full object-[center_38%]" class="w-full h-full rounded-full object-[center_38%]"
pictureClass="block w-full h-full" pictureClass="block w-full h-full"
/> />
</div> </div>
<h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{$text["services"]?.helpdesk?.title || "Helpdesk"}</h3> <h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{{ text.services?.helpdesk?.title || "Helpdesk" }}</h3>
<p class="w-full max-w-[26ch] text-center text-base">{$text["services"]?.helpdesk?.description || "HELPDESK on MTÜ Lapikute poolt pakutav arvutiabiteenus. Teenus on suunatud tudengitele, õppejõududele ning kõikidele huvilistele."}</p> <p class="w-full max-w-[26ch] text-center text-base">{{ text.services?.helpdesk?.description || "HELPDESK on MTÜ Lapikute poolt pakutav arvutiabiteenus. Teenus on suunatud tudengitele, õppejõududele ning kõikidele huvilistele." }}</p>
</Center> </Center>
<Center dir="col" class="w-full max-w-sm justify-start mx-auto md:col-span-2 lg:col-span-1"> <Center dir="col" class="w-full max-w-sm justify-start mx-auto md:col-span-2 lg:col-span-1">
<div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden"> <div class="w-[min(72vw,240px)] md:w-[min(26vw,280px)] lg:w-[min(23vw,300px)] aspect-square rounded-full overflow-hidden">
<Image <Image
webpSrc={getOptimisedImagePath("home-page-images", "company_temp", "webp")} :webpSrc="getOptimisedImagePath('home-page-images', 'company_temp', 'webp')"
src={getOptimisedImageFallback("home-page-images", "company_temp")} :src="getOptimisedImageFallback('home-page-images', 'company_temp')"
alt={$text["services"]?.company?.imageAlt || "Ettevõttele Pilt"} :alt="text['services']?.company?.imageAlt || 'Ettevõttele Pilt'"
objectFit="cover" objectFit="cover"
class="w-full h-full rounded-full object-[center_25%]" class="w-full h-full rounded-full object-[center_25%]"
pictureClass="block w-full h-full" pictureClass="block w-full h-full"
/> />
</div> </div>
<h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{$text["services"]?.company?.title || "Ettevõttele"}</h3> <h3 class="w-full min-h-10 text-center text-2xl font-semibold flex items-center justify-center">{{ text.services?.company?.title || "Ettevõttele" }}</h3>
<p class="w-full max-w-[26ch] text-center text-base">{$text["services"]?.company?.description || "Aitame sinu ideed ellu viia! Meie kogenud tudengid on valmis teie projekte realiseerima."}</p> <p class="w-full max-w-[26ch] text-center text-base">{{ text.services?.company?.description || "Aitame sinu ideed ellu viia! Meie kogenud tudengid on valmis teie projekte realiseerima." }}</p>
</Center> </Center>
</Grid> </Grid>
</Section> </Section>
@@ -153,13 +150,13 @@
bg="bg-gray-900" bg="bg-gray-900"
class="relative overflow-hidden text-white" class="relative overflow-hidden text-white"
padding="none" padding="none"
fullWidth={true} :fullWidth="true"
contentClass="!px-0 !py-0" contentClass="!px-0 !py-0"
> >
<Grid <Grid
columns={2} :columns="2"
largeColumns={2} :largeColumns="2"
mobileColumns={1} :mobileColumns="1"
gap="gap-y-0 md:gap-x-[clamp(5.75rem,9vw,9.75rem)] lg:gap-x-[clamp(6rem,9vw,10.5rem)]" gap="gap-y-0 md:gap-x-[clamp(5.75rem,9vw,9.75rem)] lg:gap-x-[clamp(6rem,9vw,10.5rem)]"
class="" class=""
> >
@@ -170,29 +167,29 @@
<div class="flex max-w-[54ch] flex-col justify-center gap-8"> <div class="flex max-w-[54ch] flex-col justify-center gap-8">
<div class="space-y-5"> <div class="space-y-5">
<h2 class="text-4xl font-semibold text-orange-500"> <h2 class="text-4xl font-semibold text-orange-500">
{$text["aboutSection"]?.title || "Meist"} {{ text.aboutSection?.title || "Meist" }}
</h2> </h2>
<p class="text-base"> <p class="text-base">
{$text["aboutSection"]?.description || "Viga teksti laadimisel. Palun proovi lehte uuesti laadida."} {{ text.aboutSection?.description || "Viga teksti laadimisel. Palun proovi lehte uuesti laadida." }}
</p> </p>
</div> </div>
<div class="flex flex-col items-start gap-6"> <div class="flex flex-col items-start gap-6">
<Button <Button
class="rounded-md border-3 border-orange-500 bg-transparent px-10 py-2 transition-colors hover:bg-orange-500/10" class="rounded-md border-3 border-orange-500 bg-transparent px-10 py-2 transition-colors hover:bg-orange-500/10"
onClick={() => navigate($currentLang === "en" ? "/aboutus" : "/lapikutest")} :onClick="() => navigate(currentLang === 'en' ? '/aboutus' : '/lapikutest')"
> >
{$text["aboutSection"]?.moreInfo || "Rohkem infot"} {{ text.aboutSection?.moreInfo || "Rohkem infot" }}
</Button> </Button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Button <Button
class="border-orange-500 bg-orange-500 px-20 py-2 transition-colors hover:border-orange-300 hover:bg-orange-300" class="border-orange-500 bg-orange-500 px-20 py-2 transition-colors hover:border-orange-300 hover:bg-orange-300"
onClick={() => navigate($currentLang === "en" ? "/contact" : "/kontakt")} :onClick="() => navigate(currentLang === 'en' ? '/contact' : '/kontakt')"
> >
{$text["aboutSection"]?.contact || "Kontakt"} {{ text.aboutSection?.contact || "Kontakt" }}
</Button> </Button>
<ArrowRight class="h-12 w-12 text-orange-500 md:h-14 md:w-14" strokeWidth={2.4} /> <ArrowRight class="h-12 w-12 text-orange-500 md:h-14 md:w-14" :strokeWidth="2.4" />
</div> </div>
</div> </div>
</div> </div>
@@ -201,9 +198,9 @@
<div class="relative overflow-hidden min-h-90 md:h-full md:self-stretch"> <div class="relative overflow-hidden min-h-90 md:h-full md:self-stretch">
<div class="absolute inset-0"> <div class="absolute inset-0">
<Image <Image
webpSrc={getOptimisedImagePath("home-page-images", "about_us", "webp")} :webpSrc="getOptimisedImagePath('home-page-images', 'about_us', 'webp')"
src={getOptimisedImageFallback("home-page-images", "about_us")} :src="getOptimisedImageFallback('home-page-images', 'about_us')"
alt={$text["aboutSection"]?.imageAlt || "Lapikud team"} :alt="text['aboutSection']?.imageAlt || 'Lapikud team'"
objectFit="cover" objectFit="cover"
class="h-full w-full max-w-none!" class="h-full w-full max-w-none!"
pictureClass="block h-full w-full" pictureClass="block h-full w-full"
@@ -232,7 +229,7 @@
<Section <Section
class="relative overflow-hidden" class="relative overflow-hidden"
padding="none" padding="none"
fullWidth={true} :fullWidth="true"
contentClass="!px-0 !py-0" contentClass="!px-0 !py-0"
> >
<!-- Decorative SVGs --> <!-- Decorative SVGs -->
@@ -292,18 +289,18 @@
<!-- End of Decorative SVGs --> <!-- End of Decorative SVGs -->
<Grid <Grid
columns={2} :columns="2"
largeColumns={2} :largeColumns="2"
mobileColumns={1} :mobileColumns="1"
gap="gap-y-0 md:gap-x-[clamp(5.75rem,9vw,9.75rem)] lg:gap-x-[clamp(6rem,9vw,10.5rem)]" gap="gap-y-0 md:gap-x-[clamp(5.75rem,9vw,9.75rem)] lg:gap-x-[clamp(6rem,9vw,10.5rem)]"
class="pb-10" class="pb-10"
> >
<div class="relative overflow-hidden min-h-90 md:min-h-0 md:h-full md:self-stretch"> <div class="relative overflow-hidden min-h-90 md:min-h-0 md:h-full md:self-stretch">
<div class="absolute inset-0"> <div class="absolute inset-0">
<Image <Image
webpSrc={getOptimisedImagePath("home-page-images", "temp", "webp")} :webpSrc="getOptimisedImagePath('home-page-images', 'temp', 'webp')"
src={getOptimisedImageFallback("home-page-images", "temp")} :src="getOptimisedImageFallback('home-page-images', 'temp')"
alt={$text["whatWeDo"]?.imageAlt || "Lapikud parandamas riistvara"} :alt="text['whatWeDo']?.imageAlt || 'Lapikud parandamas riistvara'"
objectFit="cover" objectFit="cover"
class="h-full w-full max-w-none!" class="h-full w-full max-w-none!"
pictureClass="block h-full w-full" pictureClass="block h-full w-full"
@@ -318,28 +315,28 @@
<div class="ml-auto flex max-w-[54ch] flex-col justify-center gap-8 text-right"> <div class="ml-auto flex max-w-[54ch] flex-col justify-center gap-8 text-right">
<div class="space-y-5"> <div class="space-y-5">
<h2 class="text-4xl font-semibold text-orange-500"> <h2 class="text-4xl font-semibold text-orange-500">
{$text["whatWeDo"]?.title || "Mida teeme"} {{ text.whatWeDo?.title || "Mida teeme" }}
</h2> </h2>
<p class="text-base"> <p class="text-base">
{$text["whatWeDo"]?.description || "Viga teksti laadimisel. Palun proovi lehte uuesti laadida."} {{ text.whatWeDo?.description || "Viga teksti laadimisel. Palun proovi lehte uuesti laadida." }}
</p> </p>
</div> </div>
<div class="flex flex-col items-end gap-6"> <div class="flex flex-col items-end gap-6">
<Button <Button
class="rounded-md border-3 border-orange-500 bg-transparent px-10 py-2 transition-colors hover:bg-orange-500/10" class="rounded-md border-3 border-orange-500 bg-transparent px-10 py-2 transition-colors hover:bg-orange-500/10"
onClick={() => navigate($currentLang === "en" ? "/ourwork" : "/ettevottele")} :onClick="() => navigate(currentLang === 'en' ? '/ourwork' : '/ettevottele')"
> >
{$text["whatWeDo"]?.moreInfo || "Rohkem infot"} {{ text.whatWeDo?.moreInfo || "Rohkem infot" }}
</Button> </Button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ArrowLeft class="h-12 w-12 text-orange-500 md:h-14 md:w-14" strokeWidth={2.4} /> <ArrowLeft class="h-12 w-12 text-orange-500 md:h-14 md:w-14" :strokeWidth="2.4" />
<Button <Button
class="border-orange-500 bg-orange-500 px-20 py-2 transition-colors hover:border-orange-300 hover:bg-orange-300" class="border-orange-500 bg-orange-500 px-20 py-2 transition-colors hover:border-orange-300 hover:bg-orange-300"
onClick={() => navigate($currentLang === "en" ? "/student" : "/tudengile")} :onClick="() => navigate(currentLang === 'en' ? '/student' : '/tudengile')"
> >
{$text["whatWeDo"]?.contact || "Kontakt"} {{ text.whatWeDo?.contact || "Kontakt" }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -352,54 +349,55 @@
<Section> <Section>
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl text-orange-500"> <h2 class="text-3xl text-orange-500">
{$text["partners"]?.title || "Koostööpartnerid"} {{ text.partners?.title || "Koostööpartnerid" }}
</h2> </h2>
</div> </div>
<Center class="flex-wrap"> <Center class="flex-wrap">
{#each partners as partner} <template v-for="partner in partners">
{#if partner.url} <template v-if="partner.url">
<a <a
href={partner.url} :href="partner.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="flex h-[170px] items-center justify-center p-3 no-underline transition-all duration-300 hover:scale-105" class="flex h-[170px] items-center justify-center p-3 no-underline transition-all duration-300 hover:scale-105"
title={partner.name} :title="partner.name"
> >
{#if partner.image} <template v-if="partner.image">
<img <img
src={getPartnerLogoPath(partner.image)} :src="getPartnerLogoPath(partner.image)"
alt={partner.name} :alt="partner.name"
class="w-auto max-h-[150px] transition-all duration-300 object-contain" class="w-auto max-h-[150px] transition-all duration-300 object-contain"
/> />
{:else} </template><template v-else>
<span class="font-semibold text-center">{partner.name}</span> <span class="font-semibold text-center">{{ partner.name }}</span>
{/if} </template>
</a> </a>
{:else} </template><template v-else>
<div class="flex h-[170px] items-center justify-center p-3 transition-all duration-300"> <div class="flex h-[170px] items-center justify-center p-3 transition-all duration-300">
{#if partner.image} <template v-if="partner.image">
<img <img
src={getPartnerLogoPath(partner.image)} :src="getPartnerLogoPath(partner.image)"
alt={partner.name} :alt="partner.name"
class="w-auto max-h-[150px] transition-all duration-300 object-contain" class="w-auto max-h-[150px] transition-all duration-300 object-contain"
/> />
{:else} </template><template v-else>
<span class="font-semibold text-center">{partner.name}</span> <span class="font-semibold text-center">{{ partner.name }}</span>
{/if} </template>
</div> </div>
{/if} </template>
{/each} </template>
</Center> </Center>
<Center class="m-10"> <Center class="m-10">
<Button <Button
class="rounded-md border-orange-500 bg-transparent px-20 py-3 transition-colors hover:bg-orange-500/10" class="rounded-md border-orange-500 bg-transparent px-20 py-3 transition-colors hover:bg-orange-500/10"
onClick={() => navigate("/partners")} :onClick="() => navigate('/partners')"
> >
{$text["partners"]?.moreInfo || "Rohkem infot partnerluste kohta"} {{ text.partners?.moreInfo || "Rohkem infot partnerluste kohta" }}
</Button> </Button>
</Center> </Center>
</Section> </Section>
<!-- Spacer--> <!-- Spacer-->
<Section bg="bg-orange-500" class="h-3"/> <Section bg="bg-orange-500" class="h-3"/>
</template>

View File

@@ -1,22 +1,22 @@
<script> <script setup>
import { import {
Section, Section,
Grid, Grid,
Container, Container,
Image, Image,
} from "$components"; } from "../components/index.js";
import { onMount, onDestroy } from "svelte"; import { onMounted, ref } from 'vue';
import yaml from "js-yaml"; import yaml from "js-yaml";
import { currentLang, getLangText, createPageTextStore } from "$lib"; import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "$lib/imageHelpers.js"; import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import Mail from "lucide-svelte/icons/mail"; import { Mail } from "@lucide/vue";
import Phone from "lucide-svelte/icons/phone"; import { Phone } from "@lucide/vue";
let currentManagement = []; const currentManagement = ref([]);
let pastManagement = []; const pastManagement = ref([]);
let loading = true; const loading = ref(true);
let loadError = false; const loadError = ref(false);
const text = createPageTextStore("Management"); const text = usePageText("Management");
function parseYearLabel(label) { function parseYearLabel(label) {
const value = String(label ?? ""); const value = String(label ?? "");
@@ -43,32 +43,29 @@
); );
} }
onMount(async () => { onMounted(async () => {
try { try {
const currentRes = await fetch('/_data/management.yml'); const currentRes = await fetch('/_data/management.yml');
const currentYaml = await currentRes.text(); const currentYaml = await currentRes.text();
currentManagement = yaml.load(currentYaml) || []; currentManagement.value = yaml.load(currentYaml) || [];
const pastRes = await fetch('/_data/past_management.yml'); const pastRes = await fetch('/_data/past_management.yml');
const pastYaml = await pastRes.text(); const pastYaml = await pastRes.text();
pastManagement = yaml.load(pastYaml) || []; pastManagement.value = yaml.load(pastYaml) || [];
} catch (error) { } catch (error) {
console.error('Error loading management data:', error); console.error('Error loading management data:', error);
loadError = true; loadError.value = true;
} finally { } finally {
loading = false; loading.value = false;
} }
}); });
onDestroy(() => {
text.destroy();
});
</script> </script>
<template>
<div class="safe-area-navbar"> <div class="safe-area-navbar">
<Section <Section
padding="none" padding="none"
fullWidth={true} :fullWidth="true"
contentClass="!px-0 !py-0" contentClass="!px-0 !py-0"
class="overflow-hidden text-white" class="overflow-hidden text-white"
> >
@@ -78,9 +75,9 @@
<Container class="relative z-10 py-[clamp(3rem,8vw,6rem)]"> <Container class="relative z-10 py-[clamp(3rem,8vw,6rem)]">
<div class="max-w-4xl"> <div class="max-w-4xl">
<p class="mb-4 text-xs tracking-[0.16em] uppercase text-orange-500">{$text.hero?.eyebrow || "MTÜ Lapikud"}</p> <p class="mb-4 text-xs tracking-[0.16em] uppercase text-orange-500">{{ text.hero?.eyebrow || "MTÜ Lapikud" }}</p>
<h1 class="m-0 text-[clamp(2.4rem,6vw,4.2rem)] font-bold">{$text.hero?.title || "Management"}</h1> <h1 class="m-0 text-[clamp(2.4rem,6vw,4.2rem)] font-bold">{{ text.hero?.title || "Management" }}</h1>
<p class="mt-4 max-w-[52ch] text-[clamp(1.05rem,1.5vw,1.25rem)] text-white/75">{$text.hero?.intro || "People who keep the organisation moving forward."}</p> <p class="mt-4 max-w-[52ch] text-[clamp(1.05rem,1.5vw,1.25rem)] text-white/75">{{ text.hero?.intro || "People who keep the organisation moving forward." }}</p>
</div> </div>
</Container> </Container>
</div> </div>
@@ -88,40 +85,40 @@
<!-- Current Management Section --> <!-- Current Management Section -->
<Section padding="large"> <Section padding="large">
<h1 class="m-0 text-[clamp(1.8rem,3vw,2.6rem)] leading-[1.12] tracking-[-0.03em] mb-10">{$text.current?.label || "Current board"}</h1> <h1 class="m-0 text-[clamp(1.8rem,3vw,2.6rem)] leading-[1.12] tracking-[-0.03em] mb-10">{{ text.current?.label || "Current board" }}</h1>
<Grid min="500px"> <Grid min="500px">
{#each currentManagement as member (member.name)} <template v-for="member in currentManagement" :key="member.name">
<div class="flex flex-col sm:flex-row gap-6 items-start"> <div class="flex flex-col sm:flex-row gap-6 items-start">
<div class="w-full sm:w-auto sm:max-w-[200px] aspect-square shrink-0"> <div class="w-full sm:w-auto sm:max-w-[200px] aspect-square shrink-0">
<picture> <picture>
<source srcset={getOptimisedImagePath("management-images", member.photo, "webp")} type="image/webp" /> <source :srcset="getOptimisedImagePath('management-images', member.photo, 'webp')" type="image/webp" />
<img <img
src={getOptimisedImageFallback("management-images", member.photo)} :src="getOptimisedImageFallback('management-images', member.photo)"
alt={member.name} :alt="member.name"
class="w-full h-full object-cover rounded-md" class="w-full h-full object-cover rounded-md"
/> />
</picture> </picture>
</div> </div>
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<h3 class="text-2xl font-medium">{member.name}</h3> <h3 class="text-2xl font-medium">{{ member.name }}</h3>
<p class="text-base text-gray-700"> <p class="text-base text-gray-700">
{getLangText(member, 'role', $currentLang)} - {getLangText(member, 'subrole', $currentLang)} {{ getLangText(member, 'role', currentLang) }} - {{ getLangText(member, 'subrole', currentLang) }}
</p> </p>
<a <a
href="mailto:{member.email}" :href="`mailto:${member.email}`"
class="text-base flex items-center gap-2 text-orange-500 hover:opacity-80 transition" class="text-base flex items-center gap-2 text-orange-500 hover:opacity-80 transition"
> >
<Mail size={18} />{member.email} <Mail :size="18" />{{ member.email }}
</a> </a>
<a <a
href="tel:{member.phone}" :href="`tel:${member.phone}`"
class="text-base flex items-center gap-2 text-orange-500 hover:opacity-80 transition" class="text-base flex items-center gap-2 text-orange-500 hover:opacity-80 transition"
> >
<Phone size={18} />{member.phone} <Phone :size="18" />{{ member.phone }}
</a> </a>
</div> </div>
</div> </div>
{/each} </template>
</Grid> </Grid>
</Section> </Section>
@@ -129,52 +126,53 @@
<Section padding="large"> <Section padding="large">
<div class="mb-7 flex flex-col items-start justify-between gap-6 md:flex-row md:items-end"> <div class="mb-7 flex flex-col items-start justify-between gap-6 md:flex-row md:items-end">
<div> <div>
<h2 class="m-0 text-[clamp(1.8rem,3vw,2.6rem)] leading-[1.12] tracking-[-0.03em]">{$text.history?.label || "People who have led the organisation over time"}</h2> <h2 class="m-0 text-[clamp(1.8rem,3vw,2.6rem)] leading-[1.12] tracking-[-0.03em]">{{ text.history?.label || "People who have led the organisation over time" }}</h2>
<p class="mt-2 text-[0.98rem] text-[rgba(13,13,13,0.62)]">{$text.history?.hint || "Thanks to everyone who has helped guide Lapikud"}</p> <p class="mt-2 text-[0.98rem] text-[rgba(13,13,13,0.62)]">{{ text.history?.hint || "Thanks to everyone who has helped guide Lapikud" }}</p>
</div> </div>
</div> </div>
{#if loading} <template v-if="loading">
<p class="m-0 py-4 text-black">{$text.loading || "Loading data..."}</p> <p class="m-0 py-4 text-black">{{ text.loading || "Loading data..." }}</p>
{:else if loadError} </template><template v-else-if="loadError">
<p class="m-0 py-4 text-red-500">{$text.error || "Failed to load the management data."}</p> <p class="m-0 py-4 text-red-500">{{ text.error || "Failed to load the management data." }}</p>
{:else} </template><template v-else>
<div class="flex flex-col gap-10"> <div class="flex flex-col gap-10">
{#each pastManagement as yearData (yearData.year)} <template v-for="yearData in pastManagement" :key="yearData.year">
<section class="mb-5"> <section class="mb-5">
<div class="mb-4 flex w-full flex-col items-start gap-3 border-b border-orange-500 pb-3"> <div class="mb-4 flex w-full flex-col items-start gap-3 border-b border-orange-500 pb-3">
<div class="flex items-baseline gap-3 whitespace-nowrap"> <div class="flex items-baseline gap-3 whitespace-nowrap">
<span class="text-[1.6rem] leading-none font-bold text-orange-500 tracking-[-0.04em]">{parseYearLabel(yearData.year).year}</span> <span class="text-[1.6rem] leading-none font-bold text-orange-500 tracking-[-0.04em]">{{ parseYearLabel(yearData.year).year }}</span>
{#if parseYearLabel(yearData.year).tag} <template v-if="parseYearLabel(yearData.year).tag">
<span class="rounded-full px-[0.55rem] py-[0.2rem] text-[0.64rem] tracking-widest uppercase self-center text-orange-500">{parseYearLabel(yearData.year).tag}</span> <span class="rounded-full px-[0.55rem] py-[0.2rem] text-[0.64rem] tracking-widest uppercase self-center text-orange-500">{{ parseYearLabel(yearData.year).tag }}</span>
{/if} </template>
</div> </div>
</div> </div>
<Grid min="150px" mobileColumns={2} gap="gap-5" class="items-start"> <Grid min="150px" :mobileColumns="2" gap="gap-5" class="items-start">
{#each yearData.members as member (member.name)} <template v-for="member in yearData.members" :key="member.name">
<div class="group flex flex-col items-center gap-2.5 text-center"> <div class="group flex flex-col items-center gap-2.5 text-center">
<div class="h-24 w-24 overflow-hidden rounded-[10px] bg-linear-to-br outline-2 outline-transparent outline-offset-2 transition [@media(min-width:769px)]:h-28 [@media(min-width:769px)]:w-28"> <div class="h-24 w-24 overflow-hidden rounded-[10px] bg-linear-to-br outline-2 outline-transparent outline-offset-2 transition [@media(min-width:769px)]:h-28 [@media(min-width:769px)]:w-28">
{#if member.image} <template v-if="member.image">
<Image <Image
webpSrc={getOptimisedImagePath("past-management-images", member.image, "webp")} :webpSrc="getOptimisedImagePath('past-management-images', member.image, 'webp')"
src={getOptimisedImageFallback("past-management-images", member.image)} :src="getOptimisedImageFallback('past-management-images', member.image)"
alt={member.name} :alt="member.name"
objectFit="cover" objectFit="cover"
class="block h-full w-full" class="block h-full w-full"
pictureClass="h-full w-full" pictureClass="h-full w-full"
/> />
{:else} </template><template v-else>
<div class="grid h-full w-full place-items-center text-sm font-bold text-orange-600">{getInitials(member.name)}</div> <div class="grid h-full w-full place-items-center text-sm font-bold text-orange-600">{{ getInitials(member.name) }}</div>
{/if} </template>
</div> </div>
<span class="max-w-[14ch] text-[0.86rem] font-semibold leading-[1.35] text-gray-900">{member.name}</span> <span class="max-w-[14ch] text-[0.86rem] font-semibold leading-[1.35] text-gray-900">{{ member.name }}</span>
</div> </div>
{/each} </template>
</Grid> </Grid>
</section> </section>
{/each} </template>
</div> </div>
{/if} </template>
</Section> </Section>
</div> </div>
</template>

View File

@@ -1,27 +1,23 @@
<script> <script setup>
import { Section, Container } from "$components"; import { Section, Container } from "../components/index.js";
import { onMount, onDestroy } from "svelte"; import { onMounted, ref } from 'vue';
import yaml from "js-yaml"; import yaml from "js-yaml";
import { currentLang, getLangText, createPageTextStore } from "$lib"; import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "$lib/imageHelpers.js"; import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import Mail from "lucide-svelte/icons/mail"; import { Mail } from "@lucide/vue";
import Phone from "lucide-svelte/icons/phone"; import { Phone } from "@lucide/vue";
import MessageCircle from "lucide-svelte/icons/message-circle"; import { MessageCircle } from "@lucide/vue";
import User from "lucide-svelte/icons/user"; import { User } from "@lucide/vue";
let mentors = []; const mentors = ref([]);
const text = createPageTextStore("Mentors"); const text = usePageText("Mentors");
onMount(async () => { onMounted(async () => {
const response = await fetch("/_data/mentors.yml"); const response = await fetch("/_data/mentors.yml");
const yamlText = await response.text(); const yamlText = await response.text();
mentors = yaml.load(yamlText); mentors.value = yaml.load(yamlText) || [];
});
onDestroy(() => {
text.destroy();
}); });
function getContactIcon(contactType) { function getContactIcon(contactType) {
@@ -53,11 +49,12 @@
} }
</script> </script>
<template>
<div class="safe-area-navbar"> <div class="safe-area-navbar">
<!-- Hero Section --> <!-- Hero Section -->
<Section <Section
padding="none" padding="none"
fullWidth={true} :fullWidth="true"
contentClass="!px-0 !py-0" contentClass="!px-0 !py-0"
class="overflow-hidden text-white" class="overflow-hidden text-white"
> >
@@ -67,8 +64,8 @@
<Container class="relative z-10 py-[clamp(3rem,8vw,6rem)]"> <Container class="relative z-10 py-[clamp(3rem,8vw,6rem)]">
<div class="max-w-4xl"> <div class="max-w-4xl">
<h1 class="m-0 text-[clamp(2.4rem,6vw,4.2rem)] font-bold">{$text.hero?.title || "Our Mentors"}</h1> <h1 class="m-0 text-[clamp(2.4rem,6vw,4.2rem)] font-bold">{{ text.hero?.title || "Our Mentors" }}</h1>
<p class="mt-4 max-w-[52ch] text-[clamp(1.05rem,1.5vw,1.25rem)] text-white/75">{$text.hero?.intro || "Lapikud who are always ready to support you in your studies and personal development."}</p> <p class="mt-4 max-w-[52ch] text-[clamp(1.05rem,1.5vw,1.25rem)] text-white/75">{{ text.hero?.intro || "Lapikud who are always ready to support you in your studies and personal development." }}</p>
</div> </div>
</Container> </Container>
</div> </div>
@@ -77,119 +74,115 @@
<!-- Mentors Grid --> <!-- Mentors Grid -->
<Section> <Section>
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
{#each mentors as mentor} <template v-for="mentor in mentors">
<div> <div>
<div class="flex flex-col md:flex-row gap-6 m-3"> <div class="flex flex-col md:flex-row gap-6 m-3">
<!-- Mentor Photo --> <!-- Mentor Photo -->
{#if mentor.photo} <template v-if="mentor.photo">
<div class="shrink-0"> <div class="shrink-0">
<picture> <picture>
<source srcset={getOptimisedImagePath("mentors-images", mentor.photo, "webp")} type="image/webp" /> <source :srcset="getOptimisedImagePath('mentors-images', mentor.photo, 'webp')" type="image/webp" />
<img <img
src={getOptimisedImageFallback("mentors-images", mentor.photo)} :src="getOptimisedImageFallback('mentors-images', mentor.photo)"
alt={mentor.name} :alt="mentor.name"
class="w-full md:w-48 h-48 object-cover" class="w-full md:w-48 h-48 object-cover"
/> />
</picture> </picture>
</div> </div>
{/if} </template>
<!-- Mentor Info --> <!-- Mentor Info -->
<div class="flex-1"> <div class="flex-1">
<h3 class="text-2xl font-bold mb-2">{mentor.name}</h3> <h3 class="text-2xl font-bold mb-2">{{ mentor.name }}</h3>
{#if mentor.age} <template v-if="mentor.age">
<p class="text-gray-600 mb-2"> <p class="text-gray-600 mb-2">
Vanus: {mentor.age} Vanus: {{ mentor.age }}
</p> </p>
{/if} </template>
{#if mentor.speciality} <template v-if="mentor.speciality">
<p <p
class="text-lg font-semibold mb-2" class="text-lg font-semibold mb-2"
style="color: var(--orange)" style="color: var(--orange)"
> >
{getLangText( {{ getLangText(
mentor, mentor,
"speciality", "speciality",
$currentLang, currentLang,
)} ) }}
</p> </p>
{/if} </template>
{#if mentor.teams && mentor.teams.length > 0} <template v-if="mentor.teams && mentor.teams.length > 0">
<div class="mb-3"> <div class="mb-3">
<span class="font-semibold">Meeskonnad: </span> <span class="font-semibold">Meeskonnad: </span>
<span class="text-gray-700" <span class="text-gray-700"
>{mentor.teams.join(", ")}</span >{{ mentor.teams.join(", ") }}</span
> >
</div> </div>
{/if} </template>
{#if mentor.term} <template v-if="mentor.term">
<p class="text-gray-600 mb-3"> <p class="text-gray-600 mb-3">
Ametiperiood: {mentor.term} Ametiperiood: {{ mentor.term }}
{mentor.term === 1 ? "aasta" : "aastat"} {{ mentor.term === 1 ? "aasta" : "aastat" }}
</p> </p>
{/if} </template>
{#if mentor.description} <template v-if="mentor.description">
<p <p
class="text-sm font-semibold mb-2" class="text-sm font-semibold mb-2"
style="color: var(--orange)" style="color: var(--orange)"
> >
Otsin: {getLangText( Otsin: {{ getLangText(
mentor, mentor,
"description", "description",
$currentLang, currentLang,
)} ) }}
</p> </p>
{/if} </template>
{#if mentor.activities} <template v-if="mentor.activities">
<p class="text-gray-700 text-sm mb-4"> <p class="text-gray-700 text-sm mb-4">
{getLangText( {{ getLangText(
mentor, mentor,
"activities", "activities",
$currentLang, currentLang,
)} ) }}
</p> </p>
{/if} </template>
<!-- Contact Methods --> <!-- Contact Methods -->
{#if mentor.contactMethods && mentor.contactMethods.length > 0} <template v-if="mentor.contactMethods && mentor.contactMethods.length > 0">
<div class="flex flex-wrap gap-2 mt-4"> <div class="flex flex-wrap gap-2 mt-4">
{#each mentor.contactMethods as method} <template v-for="method in mentor.contactMethods">
{@const IconComponent = getContactIcon(
method.name,
)}
<a <a
href={getContactLink(method)} :href="getContactLink(method)"
class="inline-flex items-center gap-2 px-3 py-2 bg-orange-100 hover:bg-orange-200 rounded-lg transition-colors text-sm" class="inline-flex items-center gap-2 px-3 py-2 bg-orange-100 hover:bg-orange-200 rounded-lg transition-colors text-sm"
title={method.name} :title="method.name"
target={method.value.startsWith("http") :target="method.value.startsWith('http')
? "_blank" ? '_blank'
: "_self"} : '_self'"
rel={method.value.startsWith("http") :rel="method.value.startsWith('http')
? "noopener noreferrer" ? 'noopener noreferrer'
: ""} : ''"
> >
<svelte:component <component :is="getContactIcon(method.name)"
this={IconComponent} :size="16"
size={16}
/> />
<span class="capitalize" <span class="capitalize"
>{method.name}</span >{{ method.name }}</span
> >
</a> </a>
{/each} </template>
</div> </div>
{/if} </template>
</div> </div>
</div> </div>
</div> </div>
{/each} </template>
</div> </div>
</Section> </Section>
</div> </div>
</template>

View File

@@ -1,35 +1,31 @@
<script> <script setup>
import { Section, Grid, Card, Button } from "$components"; import { Section, Grid, Card, Button } from "../components/index.js";
import { onMount, onDestroy } from "svelte"; import { computed, onMounted, ref } from 'vue';
import yaml from "js-yaml"; import yaml from "js-yaml";
import { currentLang, getLangText, createPageTextStore } from "$lib"; import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "$lib/imageHelpers.js"; import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import ExternalLink from "lucide-svelte/icons/external-link"; import { ExternalLink } from "@lucide/vue";
let projects = []; const projects = ref([]);
const text = createPageTextStore("OurWork"); const text = usePageText("OurWork");
onMount(async () => { onMounted(async () => {
const response = await fetch("/_data/ourwork.yml"); const response = await fetch("/_data/ourwork.yml");
const yamlText = await response.text(); const yamlText = await response.text();
const parsed = yaml.load(yamlText) || []; const parsed = yaml.load(yamlText) || [];
projects = parsed.filter((p) => p?.title); projects.value = parsed.filter((p) => p?.title);
}); });
onDestroy(() => { const featuredProject = computed(() => projects.value[0] || null);
text.destroy(); const regularProjects = computed(() => projects.value.slice(1));
});
$: featuredProject = projects[0] || null;
$: regularProjects = projects.slice(1);
function projectTitle(project) { function projectTitle(project) {
return getLangText(project, "title", $currentLang); return getLangText(project, "title", currentLang.value);
} }
function projectDescription(project) { function projectDescription(project) {
return getLangText(project, "description", $currentLang); return getLangText(project, "description", currentLang.value);
} }
function projectImage(project) { function projectImage(project) {
@@ -51,6 +47,7 @@
} }
</script> </script>
<template>
<div class="safe-area-navbar"> <div class="safe-area-navbar">
<!-- Hero Section --> <!-- Hero Section -->
@@ -62,12 +59,12 @@
> >
<div class="relative z-10"> <div class="relative z-10">
<h1 class="max-w-[20ch] text-[clamp(2.6rem,5.5vw,4.8rem)] leading-none font-bold tracking-[-0.03em] text-white"> <h1 class="max-w-[20ch] text-[clamp(2.6rem,5.5vw,4.8rem)] leading-none font-bold tracking-[-0.03em] text-white">
{$text["intro"]?.prefix || "Meie tehtud "} {{ text.intro?.prefix || "Meie tehtud " }}
<br /> <br />
<span class="text-orange-500">{$text["intro"]?.emphasis || "tööd."}</span> <span class="text-orange-500">{{ text.intro?.emphasis || "tööd." }}</span>
</h1> </h1>
<p class="mt-5 max-w-105 text-[0.95rem] leading-[1.7] text-white/45"> <p class="mt-5 max-w-105 text-[0.95rem] leading-[1.7] text-white/45">
{$text["intro"]?.subtitle || "Valik projekte, mida meie liikmed on teostanud, veebilehtedest mobiilirakendusteni."} {{ text.intro?.subtitle || "Valik projekte, mida meie liikmed on teostanud, veebilehtedest mobiilirakendusteni." }}
</p> </p>
</div> </div>
</Section> </Section>
@@ -81,25 +78,25 @@
<div class="flex flex-col items-start justify-between gap-6 lg:flex-row lg:items-center lg:gap-10"> <div class="flex flex-col items-start justify-between gap-6 lg:flex-row lg:items-center lg:gap-10">
<div> <div>
<h2 class="mb-2 text-[clamp(1.4rem,2.5vw,2rem)] leading-[1.1] font-bold tracking-[-0.02em] text-gray-900"> <h2 class="mb-2 text-[clamp(1.4rem,2.5vw,2rem)] leading-[1.1] font-bold tracking-[-0.02em] text-gray-900">
{$text["cta"]?.title || "Alustame koostööd?"} {{ text.cta?.title || "Alustame koostööd?" }}
</h2> </h2>
<p class="max-w-95 text-[0.88rem] leading-[1.6] text-gray-900/70"> <p class="max-w-95 text-[0.88rem] leading-[1.6] text-gray-900/70">
{$text["cta"]?.description || "Võta ühendust ja arutame, kuidas saame sinu ideed ellu viia!"} {{ text.cta?.description || "Võta ühendust ja arutame, kuidas saame sinu ideed ellu viia!" }}
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Button <Button
onClick={openCtaEmail} :onClick="openCtaEmail"
class="gap-2 rounded-none! border-gray-900! bg-gray-900 px-5.5 py-3 text-[0.83rem] font-semibold text-white transition-colors hover:border-gray-800 hover:bg-gray-800" class="gap-2 rounded-none! border-gray-900! bg-gray-900 px-5.5 py-3 text-[0.83rem] font-semibold text-white transition-colors hover:border-gray-800 hover:bg-gray-800"
> >
{$text["cta"]?.emailButton || "Saada e-kiri"} <span aria-hidden="true">&rarr;</span> {{ text.cta?.emailButton || "Saada e-kiri" }} <span aria-hidden="true">&rarr;</span>
</Button> </Button>
<Button <Button
onClick={openCtaContact} :onClick="openCtaContact"
class="rounded-none! border-gray-900/35! bg-transparent px-5.5 py-3 text-[0.83rem] font-semibold text-gray-900 transition-colors hover:border-gray-900 hover:bg-gray-900/6" class="rounded-none! border-gray-900/35! bg-transparent px-5.5 py-3 text-[0.83rem] font-semibold text-gray-900 transition-colors hover:border-gray-900 hover:bg-gray-900/6"
> >
{$text["cta"]?.contactButton || "Kontaktandmed"} {{ text.cta?.contactButton || "Kontaktandmed" }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -107,92 +104,93 @@
<Section padding="large"> <Section padding="large">
<div class="mb-22"> <div class="mb-22">
<p class="mb-3 text-[0.72rem] font-bold uppercase tracking-[0.12em] text-orange-500">{$text["portfolio"]?.label || "Tehtud tööd"}</p> <p class="mb-3 text-[0.72rem] font-bold uppercase tracking-[0.12em] text-orange-500">{{ text.portfolio?.label || "Tehtud tööd" }}</p>
<h1 class="m-0 max-w-[20ch] text-[clamp(1.9rem,4vw,3.15rem)] leading-[1.08] tracking-[-0.03em]">{$text["portfolio"]?.title || "Projektid, mis räägivad enda eest."}</h1> <h1 class="m-0 max-w-[20ch] text-[clamp(1.9rem,4vw,3.15rem)] leading-[1.08] tracking-[-0.03em]">{{ text.portfolio?.title || "Projektid, mis räägivad enda eest." }}</h1>
<p class="mt-4 max-w-[62ch] leading-[1.65] text-gray-900"> <p class="mt-4 max-w-[62ch] leading-[1.65] text-gray-900">
{$text["portfolio"]?.subtitle || "Igal projektil on oma lugu. Siin on valik töödest, mida meie liikmed on aastate jooksul ellu viinud."} {{ text.portfolio?.subtitle || "Igal projektil on oma lugu. Siin on valik töödest, mida meie liikmed on aastate jooksul ellu viinud." }}
</p> </p>
</div> </div>
{#if featuredProject} <template v-if="featuredProject">
<Card class="mb-5! grid! gap-4 rounded-none! border-0! bg-white! p-0! md:grid-cols-[1.25fr_1fr]! md:gap-6" variant="default"> <Card class="mb-5! grid! gap-4 rounded-none! border-0! bg-white! p-0! md:grid-cols-[1.25fr_1fr]! md:gap-6" variant="default">
<div class="flex min-h-[220px] items-center justify-center md:min-h-[280px]"> <div class="flex min-h-[220px] items-center justify-center md:min-h-[280px]">
{#if featuredProject.photo} <template v-if="featuredProject.photo">
<img <img
src={projectImage(featuredProject)} :src="projectImage(featuredProject)"
alt={projectTitle(featuredProject)} :alt="projectTitle(featuredProject)"
class="block h-auto max-h-full w-auto max-w-full object-contain object-center shadow-[0_1px_2px_rgba(13,13,13,0.05)]" class="block h-auto max-h-full w-auto max-w-full object-contain object-center shadow-[0_1px_2px_rgba(13,13,13,0.05)]"
on:error={(event) => handleImageError(event, featuredProject.photo)} @error="(event) => handleImageError(event, featuredProject.photo)"
/> />
{:else} </template><template v-else>
<div class="grid h-full min-h-[180px] w-full place-items-center text-[0.85rem] text-gray-900">{$text["portfolio"]?.placeholder || "Pilt puudub"}</div> <div class="grid h-full min-h-[180px] w-full place-items-center text-[0.85rem] text-gray-900">{{ text.portfolio?.placeholder || "Pilt puudub" }}</div>
{/if} </template>
</div> </div>
<div class="flex h-full flex-1 flex-col justify-between gap-[clamp(0.75rem,2vw,1.5rem)] p-[clamp(1.5rem,3vw,2.5rem)]"> <div class="flex h-full flex-1 flex-col justify-between gap-[clamp(0.75rem,2vw,1.5rem)] p-[clamp(1.5rem,3vw,2.5rem)]">
<div class="flex flex-col gap-[clamp(0.45rem,1vw,0.8rem)]"> <div class="flex flex-col gap-[clamp(0.45rem,1vw,0.8rem)]">
<h2 class="m-0 text-[clamp(1.45rem,2.7vw,2rem)] leading-[1.2] tracking-[-0.02em]">{projectTitle(featuredProject)}</h2> <h2 class="m-0 text-[clamp(1.45rem,2.7vw,2rem)] leading-[1.2] tracking-[-0.02em]">{{ projectTitle(featuredProject) }}</h2>
{#if projectDescription(featuredProject)} <template v-if="projectDescription(featuredProject)">
<p class="m-0 text-[0.9rem] leading-[1.6] text-gray-900">{projectDescription(featuredProject)}</p> <p class="m-0 text-[0.9rem] leading-[1.6] text-gray-900">{{ projectDescription(featuredProject) }}</p>
{/if} </template>
</div> </div>
{#if featuredProject.url} <template v-if="featuredProject.url">
<a <a
href={featuredProject.url} :href="featuredProject.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="mt-auto inline-flex w-fit items-center gap-1.5 pt-1 font-semibold text-orange-500 hover:underline" class="mt-auto inline-flex w-fit items-center gap-1.5 pt-1 font-semibold text-orange-500 hover:underline"
> >
{$text["portfolio"]?.projectLink || "Vaata projekti"} {{ text.portfolio?.projectLink || "Vaata projekti" }}
<ExternalLink size={16} /> <ExternalLink :size="16" />
</a> </a>
{/if} </template>
</div> </div>
</Card> </Card>
{/if} </template>
<div class="ourwork-grid"> <div class="ourwork-grid">
{#each regularProjects as project} <template v-for="project in regularProjects">
<Card variant="default" class="ourwork-grid-card gap-4 rounded-none! border-0! bg-white! p-0!"> <Card variant="default" class="ourwork-grid-card gap-4 rounded-none! border-0! bg-white! p-0!">
<div class="flex aspect-16/10 items-center justify-center"> <div class="flex aspect-16/10 items-center justify-center">
{#if project.photo} <template v-if="project.photo">
<img <img
src={projectImage(project)} :src="projectImage(project)"
alt={projectTitle(project)} :alt="projectTitle(project)"
class="block h-auto max-h-full w-auto max-w-full object-contain object-center shadow-[0_1px_2px_rgba(13,13,13,0.05)]" class="block h-auto max-h-full w-auto max-w-full object-contain object-center shadow-[0_1px_2px_rgba(13,13,13,0.05)]"
on:error={(event) => handleImageError(event, project.photo)} @error="(event) => handleImageError(event, project.photo)"
/> />
{:else} </template><template v-else>
<div class="grid h-full min-h-[180px] w-full place-items-center text-[0.85rem] text-gray-900">{$text["portfolio"]?.placeholder || "Pilt puudub"}</div> <div class="grid h-full min-h-[180px] w-full place-items-center text-[0.85rem] text-gray-900">{{ text.portfolio?.placeholder || "Pilt puudub" }}</div>
{/if} </template>
</div> </div>
<div class="ourwork-card-content flex h-full flex-1 flex-col justify-between gap-[clamp(0.55rem,1vw,0.95rem)] px-5 pb-5 pt-1.5"> <div class="ourwork-card-content flex h-full flex-1 flex-col justify-between gap-[clamp(0.55rem,1vw,0.95rem)] px-5 pb-5 pt-1.5">
<div class="flex flex-col gap-[clamp(0.4rem,0.9vw,0.75rem)]"> <div class="flex flex-col gap-[clamp(0.4rem,0.9vw,0.75rem)]">
<h3 class="ourwork-card-title m-0 text-base leading-[1.2] tracking-[-0.02em]">{projectTitle(project)}</h3> <h3 class="ourwork-card-title m-0 text-base leading-[1.2] tracking-[-0.02em]">{{ projectTitle(project) }}</h3>
{#if projectDescription(project)} <template v-if="projectDescription(project)">
<p class="ourwork-card-description m-0 text-[0.9rem] leading-[1.6] text-gray-900">{projectDescription(project)}</p> <p class="ourwork-card-description m-0 text-[0.9rem] leading-[1.6] text-gray-900">{{ projectDescription(project) }}</p>
{:else} </template><template v-else>
<p class="ourwork-card-description m-0 text-[0.9rem] leading-[1.6] text-gray-900 italic">{$text["portfolio"]?.noDescription || "Kirjeldus lisamisel."}</p> <p class="ourwork-card-description m-0 text-[0.9rem] leading-[1.6] text-gray-900 italic">{{ text.portfolio?.noDescription || "Kirjeldus lisamisel." }}</p>
{/if} </template>
</div> </div>
{#if project.url} <template v-if="project.url">
<a <a
href={project.url} :href="project.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="mt-auto inline-flex w-fit items-center gap-1.5 pt-1 font-semibold text-orange-500 hover:underline" class="mt-auto inline-flex w-fit items-center gap-1.5 pt-1 font-semibold text-orange-500 hover:underline"
> >
{$text["portfolio"]?.projectLink || "Vaata projekti"} {{ text.portfolio?.projectLink || "Vaata projekti" }}
<ExternalLink size={16} /> <ExternalLink :size="16" />
</a> </a>
{/if} </template>
</div> </div>
</Card> </Card>
{/each} </template>
</div> </div>
</Section> </Section>
</div> </div>
</template>
<style> <style>
.ourwork-grid { .ourwork-grid {
@@ -202,7 +200,7 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.ourwork-grid :global(.ourwork-grid-card) { .ourwork-grid .ourwork-grid-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;

View File

@@ -1,39 +0,0 @@
<script>
import { Section, Grid } from "$components";
import { onDestroy } from "svelte";
import { createPageTextStore } from "$lib";
let sectionElement;
let imagesLoaded = 0;
const totalImages = 3;
const text = createPageTextStore("Striim");
function handleImageLoad() {
imagesLoaded++;
if (imagesLoaded === totalImages && sectionElement) {
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
onDestroy(() => {
text.destroy();
});
</script>
<div class="safe-area-navbar" bind:this={sectionElement}>
<Section fullWidth={true} contentClass="flex items-start">
<Grid min="400px" class="w-full">
<a href="https://master.lapikud.ee/stream/1/stream">
<img src="https://master.lapikud.ee/stream/1/substream" alt={$text.streamAlts?.first || "camera 1 stream"} style="width:100%;" on:load={handleImageLoad}/>
</a>
<a href="https://master.lapikud.ee/stream/2/stream">
<img src="https://master.lapikud.ee/stream/2/substream" alt={$text.streamAlts?.second || "camera 2 stream"} style="width:100%;" on:load={handleImageLoad}/>
</a>
<a href="https://master.lapikud.ee/stream/3/stream">
<img src="https://master.lapikud.ee/stream/3/substream" alt={$text.streamAlts?.third || "camera 3 stream"} style="width:100%;" on:load={handleImageLoad}/>
</a>
</Grid>
</Section>
</div>

38
src/routes/Striim.vue Normal file
View File

@@ -0,0 +1,38 @@
<script setup>
import { Section, Grid } from "../components/index.js";
import { ref } from 'vue';
import { usePageText } from "../lib/index.js";
const sectionElement = ref(null);
const imagesLoaded = ref(0);
const totalImages = 3;
const text = usePageText("Striim");
function handleImageLoad() {
imagesLoaded.value++;
if (imagesLoaded.value === totalImages && sectionElement.value) {
sectionElement.value.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
</script>
<template>
<div class="safe-area-navbar" ref="sectionElement">
<Section :fullWidth="true" contentClass="flex items-start">
<Grid min="400px" class="w-full">
<a href="https://master.lapikud.ee/stream/1/stream">
<img src="https://master.lapikud.ee/stream/1/substream" :alt="text.streamAlts?.first || 'camera 1 stream'" style="width:100%;" @load="handleImageLoad"/>
</a>
<a href="https://master.lapikud.ee/stream/2/stream">
<img src="https://master.lapikud.ee/stream/2/substream" :alt="text.streamAlts?.second || 'camera 2 stream'" style="width:100%;" @load="handleImageLoad"/>
</a>
<a href="https://master.lapikud.ee/stream/3/stream">
<img src="https://master.lapikud.ee/stream/3/substream" :alt="text.streamAlts?.third || 'camera 3 stream'" style="width:100%;" @load="handleImageLoad"/>
</a>
</Grid>
</Section>
</div>
</template>

View File

@@ -1,31 +1,27 @@
<script> <script setup>
import { import {
Section, Section,
Grid, Grid,
Button, Button,
} from "$components"; } from "../components/index.js";
import { onMount, onDestroy } from "svelte"; import { computed, onMounted, ref } from 'vue';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { createPageTextStore } from "$lib"; import { usePageText } from "../lib/index.js";
let members = { junior: [], senior: [] }; const members = ref({ junior: [], senior: [] });
const text = createPageTextStore("Student"); const text = usePageText("Student");
const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE"; const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE";
onMount(async () => { onMounted(async () => {
try { try {
const response = await fetch('/_data/members.yml'); const response = await fetch('/_data/members.yml');
const yamlText = await response.text(); const yamlText = await response.text();
members = yaml.load(yamlText) || { junior: [], senior: [] }; members.value = yaml.load(yamlText) || { junior: [], senior: [] };
} catch { } catch {
members = { junior: [], senior: [] }; members.value = { junior: [], senior: [] };
} }
}); });
onDestroy(() => {
text.destroy();
});
const scrollToJoin = () => { const scrollToJoin = () => {
document.getElementById("liitu")?.scrollIntoView({ behavior: "smooth", block: "start" }); document.getElementById("liitu")?.scrollIntoView({ behavior: "smooth", block: "start" });
}; };
@@ -38,41 +34,42 @@
window.open(joinFormUrl, "_blank", "noopener,noreferrer"); window.open(joinFormUrl, "_blank", "noopener,noreferrer");
}; };
$: teamSections = ($text["teams"]?.items || []).map((team, index) => ({ const teamSections = computed(() => (text.value.teams?.items || []).map((team, index) => ({
...team, ...team,
number: team.number || `0${index + 1}`, number: team.number || `0${index + 1}`,
})); })));
$: memberSections = [ const memberSections = computed(() => [
{ key: "junior", label: $text["members"]?.junior || "" }, { key: "junior", label: text.value.members?.junior || '' },
{ key: "senior", label: $text["members"]?.senior || "" }, { key: "senior", label: text.value.members?.senior || '' },
{ key: "graduates", label: $text["members"]?.graduates || "" }, { key: "graduates", label: text.value.members?.graduates || '' },
]; ]);
</script> </script>
<template>
<div class="safe-area-navbar"> <div class="safe-area-navbar">
<Section class="overflow-hidden bg-gray-900" padding="none" fullWidth={true} contentClass="relative !px-0"> <Section class="overflow-hidden bg-gray-900" padding="none" :fullWidth="true" contentClass="relative !px-0">
<div class="relative overflow-hidden py-[3rem]"> <div class="relative overflow-hidden py-[3rem]">
<div class="relative z-10 mx-auto w-full max-w-(--page-max-width) px-(--page-padding-inline)"> <div class="relative z-10 mx-auto w-full max-w-(--page-max-width) px-(--page-padding-inline)">
<div class="relative z-10 w-full max-w-3xl"> <div class="relative z-10 w-full max-w-3xl">
<h1 class="mb-6 text-[clamp(2.5rem,7vw,4.75rem)] leading-[1.03] font-medium text-white"> <h1 class="mb-6 text-[clamp(2.5rem,7vw,4.75rem)] leading-[1.03] font-medium text-white">
{$text["hero"]?.title || ""}<br /> {{ text.hero?.title || '' }}<br />
<span class="italic text-orange-500">{$text["hero"]?.emphasis || ""}</span> <span class="italic text-orange-500">{{ text.hero?.emphasis || '' }}</span>
</h1> </h1>
<p class="mb-10 max-w-2xl text-base leading-8 text-white/55"> <p class="mb-10 max-w-2xl text-base leading-8 text-white/55">
{$text["hero"]?.description || ""} {{ text.hero?.description || '' }}
</p> </p>
<div class="flex flex-wrap items-center gap-4"> <div class="flex flex-wrap items-center gap-4">
<Button onClick={scrollToJoin} class="border-transparent bg-orange-500 px-8 py-3 text-sm font-semibold text-white hover:opacity-90"> <Button :onClick="scrollToJoin" class="border-transparent bg-orange-500 px-8 py-3 text-sm font-semibold text-white hover:opacity-90">
{$text["hero"]?.cta || ""} {{ text.hero?.cta || '' }}
</Button> </Button>
<a href="#tiimid" on:click|preventDefault={scrollToUnderHero} class="text-sm text-white/40">{$text["hero"]?.aside || ""}</a> <a href="#tiimid" @click.prevent="scrollToUnderHero" class="text-sm text-white/40">{{ text.hero?.aside || '' }}</a>
</div> </div>
</div> </div>
</div> </div>
<div class="pointer-events-none select-none absolute right-0 bottom-0 translate-y-1/4 text-[clamp(8rem,20vw,14rem)] leading-none italic text-white/4"> <div class="pointer-events-none select-none absolute right-0 bottom-0 translate-y-1/4 text-[clamp(8rem,20vw,14rem)] leading-none italic text-white/4">
{$text["hero"]?.bgWord || "Lapikud"} {{ text.hero?.bgWord || "Lapikud" }}
</div> </div>
</div> </div>
</Section> </Section>
@@ -81,77 +78,77 @@
<Section id="tiimid" class="bg-white" padding="large"> <Section id="tiimid" class="bg-white" padding="large">
<div class="mb-14"> <div class="mb-14">
<p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-orange-500">{$text["teams"]?.kicker || ""}</p> <p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-orange-500">{{ text.teams?.kicker || '' }}</p>
<h2 class="mb-4 max-w-4xl text-[clamp(2rem,4vw,3rem)] leading-tight font-medium text-black"> <h2 class="mb-4 max-w-4xl text-[clamp(2rem,4vw,3rem)] leading-tight font-medium text-black">
{$text["teams"]?.title || ""} {{ text.teams?.title || '' }}
</h2> </h2>
<p class="max-w-2xl text-base leading-8 text-black/60"> <p class="max-w-2xl text-base leading-8 text-black/60">
{$text["teams"]?.subtitle || ""} {{ text.teams?.subtitle || '' }}
</p> </p>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
{#each teamSections as team, index} <template v-for="(team, index) in teamSections">
<div class="grid border-t border-black/10 py-12 last:border-b xl:grid-cols-[240px_1fr_1fr]"> <div class="grid border-t border-black/10 py-12 last:border-b xl:grid-cols-[240px_1fr_1fr]">
<div class="pr-10"> <div class="pr-10">
<h3 class="my-3 text-4xl leading-none font-medium text-black">{team.title}</h3> <h3 class="my-3 text-4xl leading-none font-medium text-black">{{ team.title }}</h3>
<p class="text-sm leading-5 text-black/55">{team.short || ""}</p> <p class="text-sm leading-5 text-black/55">{{ team.short || '' }}</p>
</div> </div>
<div class="pt-8 xl:border-l xl:border-black/10 xl:px-12 xl:pt-0"> <div class="pt-8 xl:border-l xl:border-black/10 xl:px-12 xl:pt-0">
{#each team.about || [] as paragraph} <template v-for="paragraph in team.about || []">
<p class="mb-5 text-[0.95rem] leading-8 text-black/80 last:mb-0">{paragraph}</p> <p class="mb-5 text-[0.95rem] leading-8 text-black/80 last:mb-0">{{ paragraph }}</p>
{/each} </template>
<div class="mt-6 flex flex-wrap gap-2"> <div class="mt-6 flex flex-wrap gap-2">
{#each team.tags || [] as tag} <template v-for="tag in team.tags || []">
<span class="px-3 py-1.5 text-xs font-medium text-orange-500">{tag}</span> <span class="px-3 py-1.5 text-xs font-medium text-orange-500">{{ tag }}</span>
{/each} </template>
</div> </div>
</div> </div>
<div class="pt-8 xl:border-l xl:border-black/10 xl:pl-12 xl:pt-0"> <div class="pt-8 xl:border-l xl:border-black/10 xl:pl-12 xl:pt-0">
<h4 class="mb-4 text-xs font-semibold uppercase tracking-widest text-black">{team.whatTitle || ""}</h4> <h4 class="mb-4 text-xs font-semibold uppercase tracking-widest text-black">{{ team.whatTitle || '' }}</h4>
<ul class="mb-8 space-y-2"> <ul class="mb-8 space-y-2">
{#each team.what || [] as item} <template v-for="item in team.what || []">
<li class="relative pl-4 text-sm leading-6 text-black/80 before:absolute before:left-0 before:top-0.5 before:text-[0.65rem] before:text-orange-500 before:content-['—']"> <li class="relative pl-4 text-sm leading-6 text-black/80 before:absolute before:left-0 before:top-0.5 before:text-[0.65rem] before:text-orange-500 before:content-['—']">
{item} {{ item }}
</li> </li>
{/each} </template>
</ul> </ul>
<h4 class="mb-4 text-xs font-semibold uppercase tracking-widest text-black">{team.eventsTitle || ""}</h4> <h4 class="mb-4 text-xs font-semibold uppercase tracking-widest text-black">{{ team.eventsTitle || '' }}</h4>
<ul class="space-y-2"> <ul class="space-y-2">
{#each team.events || [] as event} <template v-for="event in team.events || []">
<li class="relative pl-4 text-sm leading-6 text-black/80 before:absolute before:left-0 before:top-0.5 before:text-[0.65rem] before:text-black/50 before:content-['↻']"> <li class="relative pl-4 text-sm leading-6 text-black/80 before:absolute before:left-0 before:top-0.5 before:text-[0.65rem] before:text-black/50 before:content-['↻']">
{event} {{ event }}
</li> </li>
{/each} </template>
</ul> </ul>
</div> </div>
</div> </div>
{/each} </template>
</div> </div>
</Section> </Section>
<Section id="liitu" class="overflow-hidden bg-orange-500" padding="large"> <Section id="liitu" class="overflow-hidden bg-orange-500" padding="large">
<div class="relative grid max-w-5xl items-center gap-10 lg:grid-cols-[1fr_auto]"> <div class="relative grid max-w-5xl items-center gap-10 lg:grid-cols-[1fr_auto]">
<div class="pointer-events-none select-none absolute -right-6 -top-10 text-[clamp(7rem,16vw,12rem)] leading-none italic text-black/10"> <div class="pointer-events-none select-none absolute -right-6 -top-10 text-[clamp(7rem,16vw,12rem)] leading-none italic text-black/10">
{$text["join"]?.bgWord || ""} {{ text.join?.bgWord || '' }}
</div> </div>
<div class="relative"> <div class="relative">
<p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-black/45">{$text["join"]?.kicker || ""}</p> <p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-black/45">{{ text.join?.kicker || '' }}</p>
<h2 class="mb-4 text-[clamp(2rem,4vw,3.3rem)] leading-tight font-medium text-black"> <h2 class="mb-4 text-[clamp(2rem,4vw,3.3rem)] leading-tight font-medium text-black">
{$text["join"]?.title || ""}<br />{$text["join"]?.titleLine2 || ""} {{ text.join?.title || '' }}<br />{{ text.join?.titleLine2 || '' }}
</h2> </h2>
<p class="max-w-2xl text-base leading-8 text-black/70"> <p class="max-w-2xl text-base leading-8 text-black/70">
{$text["join"]?.description || ""} {{ text.join?.description || '' }}
</p> </p>
</div> </div>
<div class="relative"> <div class="relative">
<Button onClick={openJoinForm} class="border-transparent bg-black px-9 py-3 text-base font-semibold text-white hover:opacity-90"> <Button :onClick="openJoinForm" class="border-transparent bg-black px-9 py-3 text-base font-semibold text-white hover:opacity-90">
{$text["join"]?.cta || ""} {{ text.join?.cta || '' }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -161,25 +158,26 @@
<Section id="liikmed" class="bg-[#f7f6f3]" padding="large"> <Section id="liikmed" class="bg-[#f7f6f3]" padding="large">
<div class="mb-10"> <div class="mb-10">
<p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-orange-500">{$text["members"]?.kicker || ""}</p> <p class="mb-3 text-xs font-semibold uppercase tracking-[0.13em] text-orange-500">{{ text.members?.kicker || '' }}</p>
<h2 class="text-[clamp(2rem,3.6vw,2.9rem)] leading-tight font-medium text-black"> <h2 class="text-[clamp(2rem,3.6vw,2.9rem)] leading-tight font-medium text-black">
{$text["members"]?.title || ""} {{ text.members?.title || '' }}
</h2> </h2>
</div> </div>
{#each memberSections as section, idx} <template v-for="(section, idx) in memberSections">
{#if members[section.key] && members[section.key].length > 0} <template v-if="members[section.key] && members[section.key].length > 0">
<div class={idx < memberSections.length - 1 ? "mb-12" : ""}> <div :class="idx < memberSections.length - 1 ? 'mb-12' : ''">
<h3 class="mb-5 border-b border-orange-500/30 pb-3 text-xs font-semibold uppercase tracking-widest text-orange-500"> <h3 class="mb-5 border-b border-orange-500/30 pb-3 text-xs font-semibold uppercase tracking-widest text-orange-500">
{section.label} {{ section.label }}
</h3> </h3>
<Grid columns={5} tabletColumns={4} mobileColumns={2} gap="gap-x-4 gap-y-2"> <Grid :columns="5" :tabletColumns="4" :mobileColumns="2" gap="gap-x-4 gap-y-2">
{#each members[section.key] as member} <template v-for="member in members[section.key]">
<span class="py-1 text-sm leading-6 text-black/80">{member}</span> <span class="py-1 text-sm leading-6 text-black/80">{{ member }}</span>
{/each} </template>
</Grid> </Grid>
</div> </div>
{/if} </template>
{/each} </template>
</Section> </Section>
</div> </div>
</template>

View File

@@ -1,26 +1,22 @@
<script> <script setup>
import { Section, Stack, Image } from "$components"; import { Section, Stack, Image } from "../components/index.js";
import { onMount, onDestroy } from "svelte"; import { onMounted, ref } from 'vue';
import yaml from "js-yaml"; import yaml from "js-yaml";
import { currentLang, getLangText, createPageTextStore } from "$lib"; import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getRootAssetPath } from "$lib/imageHelpers.js"; import { getRootAssetPath } from "../lib/imageHelpers.js";
let workshops = []; const workshops = ref([]);
const text = createPageTextStore("Workshops"); const text = usePageText("Workshops");
onMount(async () => { onMounted(async () => {
const response = await fetch("/_data/workshops.yml"); const response = await fetch("/_data/workshops.yml");
const yamlText = await response.text(); const yamlText = await response.text();
const parsed = yaml.load(yamlText) || []; const parsed = yaml.load(yamlText) || [];
workshops = parsed.filter((w) => w?.title); workshops.value = parsed.filter((w) => w?.title);
});
onDestroy(() => {
text.destroy();
}); });
function getField(workshop, field) { function getField(workshop, field) {
return getLangText(workshop, field, $currentLang); return getLangText(workshop, field, currentLang.value);
} }
function getGallery(workshop) { function getGallery(workshop) {
@@ -48,21 +44,22 @@
} }
</script> </script>
<template>
<div class="safe-area-navbar"> <div class="safe-area-navbar">
<!-- Hero Section --> <!-- Hero Section -->
<Section bg="bg-gray-900" class="text-white"> <Section bg="bg-gray-900" class="text-white">
<div class="max-w-2xl"> <div class="max-w-2xl">
<p class="font-dm-mono text-xs uppercase tracking-widest text-orange-500 mb-4"> <p class="font-dm-mono text-xs uppercase tracking-widest text-orange-500 mb-4">
{$text["hero"]?.label} {{ text.hero?.label }}
</p> </p>
<h1 class="font-syne text-5xl md:text-6xl lg:text-7xl font-bold leading-tight mb-4"> <h1 class="font-syne text-5xl md:text-6xl lg:text-7xl font-bold leading-tight mb-4">
{$text["hero"]?.title} {{ text.hero?.title }}
<em class="block text-orange-500 not-italic" <em class="block text-orange-500 not-italic"
>{@html $text["hero"]?.titleHighlight}</em ><span v-html="text.hero?.titleHighlight"></span></em
> >
</h1> </h1>
<p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light"> <p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light">
{$text["hero"]?.subtitle} {{ text.hero?.subtitle }}
</p> </p>
</div> </div>
</Section> </Section>
@@ -70,66 +67,67 @@
<!-- Workshops Content --> <!-- Workshops Content -->
<Section> <Section>
<Stack gap="lg"> <Stack gap="lg">
{#each workshops as workshop, index (index)} <template v-for="(workshop, index) in workshops" :key="index">
<div class={`grid grid-cols-1 md:grid-cols-2 gap-14 md:gap-16 py-16 border-b border-orange-500 ${index === workshops.length - 1 ? "border-b-0" : ""}`}> <div :class="`grid grid-cols-1 md:grid-cols-2 gap-14 md:gap-16 py-16 border-b border-orange-500 ${index === workshops.length - 1 ? 'border-b-0' : ''}`">
<!-- Gallery --> <!-- Gallery -->
<div <div
class={`grid gap-1.5 ${index % 2 === 1 ? "md:order-2" : ""}`} :class="`grid gap-1.5 ${index % 2 === 1 ? 'md:order-2' : ''}`"
> >
<div class={`grid ${getGalleryClass(getGallery(workshop).length)} gap-1.5`}> <div :class="`grid ${getGalleryClass(getGallery(workshop).length)} gap-1.5`">
{#each getGallery(workshop) as filename, i (filename)} <template v-for="(filename, i) in getGallery(workshop)" :key="filename">
<div <div
class={`overflow-hidden ${getImageHeightClass(getGallery(workshop).length, i)}`} :class="`overflow-hidden ${getImageHeightClass(getGallery(workshop).length, i)}`"
> >
<Image <Image
src={getRootAssetPath("workshop-images", filename, "jpg")} :src="getRootAssetPath('workshop-images', filename, 'jpg')"
alt={`${getField(workshop, "title")} ${i + 1}`} :alt="`${getField(workshop, 'title')} ${i + 1}`"
objectFit="cover" objectFit="cover"
class="h-full w-full" class="h-full w-full"
pictureClass="h-full w-full" pictureClass="h-full w-full"
/> />
</div> </div>
{/each} </template>
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class={`pt-2 ${index % 2 === 1 ? "md:order-1" : ""}`}> <div :class="`pt-2 ${index % 2 === 1 ? 'md:order-1' : ''}`">
<h2 class="font-syne text-2xl md:text-3xl font-bold text-gray-900 mb-4"> <h2 class="font-syne text-2xl md:text-3xl font-bold text-gray-900 mb-4">
{getField(workshop, "title")} {{ getField(workshop, "title") }}
</h2> </h2>
<p class="text-sm leading-7 text-gray-600 font-space-grotesk font-light mb-7"> <p class="text-sm leading-7 text-gray-600 font-space-grotesk font-light mb-7">
{getField(workshop, "description")} {{ getField(workshop, "description") }}
</p> </p>
<div class="flex flex-wrap gap-7"> <div class="flex flex-wrap gap-7">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500"> <span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500">
{$text["details"]?.mentor} {{ text.details?.mentor }}
</span> </span>
<span class="text-sm text-gray-700 font-space-grotesk"> <span class="text-sm text-gray-700 font-space-grotesk">
{getField(workshop, "mentor")} {{ getField(workshop, "mentor") }}
</span> </span>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500"> <span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500">
{$text["details"]?.duration} {{ text.details?.duration }}
</span> </span>
<span class="text-sm text-gray-700 font-space-grotesk"> <span class="text-sm text-gray-700 font-space-grotesk">
{getField(workshop, "duration")} {{ getField(workshop, "duration") }}
</span> </span>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500"> <span class="font-dm-mono text-xs uppercase tracking-wide text-gray-500">
{$text["details"]?.date} {{ text.details?.date }}
</span> </span>
<span class="text-sm text-gray-700 font-space-grotesk"> <span class="text-sm text-gray-700 font-space-grotesk">
{getField(workshop, "date")} {{ getField(workshop, "date") }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/each} </template>
</Stack> </Stack>
</Section> </Section>
</div> </div>
</template>

View File

@@ -1,17 +1,17 @@
// ADD PAGES HERE TO REGISTER ROUTES // ADD PAGES HERE TO REGISTER ROUTES
// examples: '/page', '/page/:id' // examples: '/page', '/page/:id'
const loadHome = () => import('./Home.svelte'); const loadHome = () => import('./Home.vue');
const loadStudent = () => import('./Student.svelte'); const loadStudent = () => import('./Student.vue');
const loadMentors = () => import('./Mentors.svelte'); const loadMentors = () => import('./Mentors.vue');
const loadHelpdesk = () => import('./Helpdesk.svelte'); const loadHelpdesk = () => import('./Helpdesk.vue');
const loadOurWork = () => import('./OurWork.svelte'); const loadOurWork = () => import('./OurWork.vue');
const loadAboutUs = () => import('./AboutUs.svelte'); const loadAboutUs = () => import('./AboutUs.vue');
const loadContact = () => import('./Contact.svelte'); const loadContact = () => import('./Contact.vue');
const loadManagment = () => import('./Managment.svelte'); const loadManagment = () => import('./Managment.vue');
const loadStriim = () => import('./Striim.svelte'); const loadStriim = () => import('./Striim.vue');
const loadCalendar = () => import('./Calendar.svelte'); const loadCalendar = () => import('./Calendar.vue');
const loadWorkshops = () => import('./Workshops.svelte'); const loadWorkshops = () => import('./Workshops.vue');
// Route mapping for language switching // Route mapping for language switching
export const routeMap = { export const routeMap = {

View File

@@ -0,0 +1,13 @@
import Svg, { VARIANTS } from '../components/Svg.vue';
export default {
title: 'Components/Svg',
component: Svg,
argTypes: {
type: { control: 'select', options: VARIANTS },
},
};
export const Preview = {
args: { type: VARIANTS[0] },
};

View File

@@ -1,23 +0,0 @@
<script context="module">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Svg from '../components/Svg.svelte';
const { Story } = defineMeta({
title: 'Components/Svg',
component: Svg,
});
</script>
<Story name="instagram" args={{ type: 'instagram' }} />
<Story name="facebook" args={{ type: 'facebook' }} />
<Story name="github" args={{ type: 'github' }} />
<Story name="connector" args={{ type: 'connector' }} />
<Story name="mirrorV" args={{ type: 'mirrorV' }} />
<Story name="mirrorH" args={{ type: 'mirrorH' }} />
<Story name="mirrorVH" args={{ type: 'mirrorVH' }} />
<Story name="mirrorHR" args={{ type: 'mirrorHR' }} />
<Story name="branch" args={{ type: 'branch' }} />
<Story name="branchH" args={{ type: 'branchH' }} />
<Story name="straightH" args={{ type: 'straightH' }} />
<Story name="straightHS" args={{ type: 'straightHS' }} />
<Story name="connectorHL" args={{ type: 'connectorHL' }} />

View File

@@ -1,8 +0,0 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

View File

@@ -1,22 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
svelte() vue()
], ],
base: '/', base: '/',
server: { server: {
historyApiFallback: true, historyApiFallback: true,
},
resolve: {
alias: {
'@': '/src',
'$components': '/src/components',
'$lib': '/src/lib'
}
} }
}) })