feat: initial commit — site + admin panel + Postgres content pipeline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-07-21 01:24:47 +03:00
co-authored by Claude Fable 5
commit 3420d32271
188 changed files with 25053 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
<template>
<section class="hidden lg:block max-w-6xl mx-auto px-8 pt-20 pb-28">
<!-- TITLE -->
<div v-if="currentStep === 'parents'" class="mb-12 text-center">
<h1 class="text-5xl font-medium text-dark tracking-tight">
müco <span class="font-handwritten">{{ t("Menu") }}</span>
</h1>
</div>
<!-- BREADCRUMB -->
<nav class="mb-10 flex items-center gap-2 overflow-hidden text-xl">
<button
type="button"
class="flex shrink-0 items-center justify-center text-dark/60 transition hover:text-dark"
@click="goHome"
>
<component :is="icons.home" class="h-6 w-6" />
</button>
<span class="text-dark/30">/</span>
<button
type="button"
class="shrink-0 transition hover:text-dark"
:class="currentStep === 'parents' ? 'text-dark' : 'text-dark/55'"
@click="goMenuRoot"
>
{{ t("Main Menu") }}
</button>
<template v-if="selectedParent">
<span class="text-dark/25">/</span>
<button
v-if="hasMultipleChildCategories"
type="button"
class="truncate transition hover:text-dark"
:class="currentStep === 'categories' ? 'text-dark' : 'text-dark/55'"
@click="goToParent"
>
{{ selectedParent.title }}
</button>
<span v-else class="truncate text-dark">
{{ selectedParent.title }}
</span>
</template>
<template
v-if="
selectedCategory &&
currentStep === 'items' &&
hasMultipleChildCategories
"
>
<span class="text-dark/25">/</span>
<span class="truncate text-dark">
{{ selectedCategory.title }}
</span>
</template>
</nav>
<!-- PARENT GRID -->
<div v-if="currentStep === 'parents'" class="grid grid-cols-3 gap-10">
<button
v-for="parent in parents"
:key="parent.id"
type="button"
class="group text-left"
@click="selectParent(parent.id)"
>
<div
class="relative aspect-square overflow-hidden rounded-4xl bg-light"
>
<img
:src="getImageSrc(parent.image)"
:alt="getImageAlt(parent.image, parent.title)"
class="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
@error="setFallbackImage"
/>
<div
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
/>
<div class="absolute inset-x-0 bottom-0 p-6 text-white">
<div
class="absolute -top-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/15 backdrop-blur transition group-hover:bg-white group-hover:text-dark"
>
<component :is="icons.arrowRight" class="h-5 w-5" />
</div>
<h2 class="text-2xl font-medium tracking-tight">
{{ parent.title }}
</h2>
</div>
</div>
</button>
</div>
<!-- CATEGORY GRID -->
<div v-else-if="currentStep === 'categories'">
<div class="mb-10">
<h2 class="text-4xl font-medium tracking-tight text-dark">
{{ selectedParent?.title }}
</h2>
</div>
<div class="grid grid-cols-2 xl:grid-cols-3 gap-10">
<button
v-for="category in childCategories"
:key="category.title"
type="button"
class="group text-left"
@click="selectCategory(category.title)"
>
<div
class="relative aspect-square overflow-hidden rounded-4xl bg-light"
>
<img
:src="getImageSrc(category.image)"
:alt="getImageAlt(category.image, category.title)"
class="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
@error="setFallbackImage"
/>
<div
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
/>
<div class="absolute inset-x-0 bottom-0 p-6 text-white">
<div
class="absolute -top-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/15 backdrop-blur transition group-hover:bg-white group-hover:text-dark"
>
<component :is="icons.arrowRight" class="h-5 w-5" />
</div>
<h3 class="text-2xl font-medium tracking-tight">
{{ category.title }}
</h3>
<p
v-if="category.description"
class="mt-2 max-w-xs text-sm leading-relaxed text-white/70"
>
{{ category.description }}
</p>
</div>
</div>
</button>
</div>
</div>
<!-- PRODUCT DETAIL -->
<div v-else>
<div class="mb-12 grid grid-cols-[1.1fr_0.9fr] gap-10 items-end">
<!-- HERO -->
<div class="overflow-hidden rounded-[2.5rem] bg-dark">
<div class="relative aspect-video">
<img
:src="
getImageSrc(selectedCategory?.image || selectedParent?.image)
"
:alt="
getImageAlt(
selectedCategory?.image || selectedParent?.image,
selectedCategory?.title || selectedParent?.title || t('Menu'),
)
"
class="absolute inset-0 h-full w-full object-cover opacity-85"
@error="setFallbackImage"
/>
<div
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent"
/>
<div class="absolute inset-x-0 bottom-0 p-8 text-white">
<p
v-if="selectedParent?.title"
class="mb-2 text-xs font-medium uppercase tracking-[0.24em] text-white/60"
>
{{ selectedParent.title }}
</p>
<h2
class="text-5xl font-black uppercase leading-none tracking-tight"
>
{{ selectedCategory?.title || selectedParent?.title }}
</h2>
<p
v-if="selectedCategory?.description"
class="max-w-xl text-sm leading-relaxed text-white/70"
>
{{ selectedCategory.description }}
</p>
</div>
</div>
</div>
<!-- VISUAL TABS -->
<div
v-if="hasMultipleChildCategories"
class="overflow-x-auto no-scrollbar"
>
<div class="flex gap-4 pb-2">
<button
v-for="category in childCategories"
:key="category.title"
type="button"
class="group w-44 shrink-0 text-left"
@click="selectCategory(category.title)"
>
<div
:class="[
'overflow-hidden rounded-2xl transition-all duration-300',
selectedCategoryTitle === category.title
? 'scale-100 opacity-100'
: 'scale-[0.96] opacity-50 hover:opacity-80',
]"
>
<div class="relative aspect-square bg-light">
<img
:src="getImageSrc(category.image)"
:alt="getImageAlt(category.image, category.title)"
class="absolute inset-0 h-full w-full object-cover transition duration-500 group-hover:scale-105"
@error="setFallbackImage"
/>
<div
class="absolute inset-0 bg-linear-to-t from-black/80 via-black/10 to-transparent"
/>
<div class="absolute inset-x-0 bottom-0 p-4 text-white">
<p class="text-sbase font-medium leading-tight tracking-tight">
{{ category.title }}
</p>
</div>
</div>
</div>
</button>
</div>
</div>
</div>
<!-- ITEMS -->
<div class="grid grid-cols-2 gap-x-16 gap-y-5">
<div
v-for="(item, index) in currentItems"
:key="item.title"
:class="[
'flex items-start justify-between gap-6 pb-3',
index !== currentItems.length - 1 ? 'border-b border-dark/10' : '',
]"
>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-medium text-dark">
{{ item.title }}
</h3>
<p
v-if="item.description"
class="mt-2 text-sm leading-relaxed text-dark/50"
>
{{ item.description }}
</p>
</div>
<span
v-if="item.price"
class="shrink-0 text-base font-semibold text-dark"
>
{{ item.price }}
</span>
</div>
</div>
<p
v-if="!currentItems.length"
class="py-16 text-center text-sm text-dark/40"
>
{{ t("No items found") }}
</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useIcons } from "@/composables/useIcons";
const icons = useIcons();
type Image = {
src?: string;
alt?: string;
};
type MenuParent = {
id: number;
title: string;
image?: Image;
};
type MenuItem = {
title: string;
description?: string;
image?: Image;
price?: string;
};
type MenuCategory = {
parent?: MenuParent;
title: string;
description?: string;
image?: Image;
items?: MenuItem[];
};
type Step = "parents" | "categories" | "items";
const props = withDefaults(
defineProps<{
categories: MenuCategory[];
fallbackImage?: string;
}>(),
{
fallbackImage: "/images/general-img-square.webp",
},
);
const emit = defineEmits<{
back: [];
}>();
const { t } = useI18n();
const selectedParentId = ref<number | null>(null);
const selectedCategoryTitle = ref<string | null>(null);
const parents = computed<MenuParent[]>(() => {
const map = new Map<number, MenuParent>();
props.categories.forEach((category) => {
if (!category.parent) return;
if (!map.has(category.parent.id)) {
map.set(category.parent.id, category.parent);
}
});
return Array.from(map.values()).sort((a, b) => a.id - b.id);
});
const selectedParent = computed<MenuParent | undefined>(() => {
return parents.value.find((parent) => parent.id === selectedParentId.value);
});
const childCategories = computed<MenuCategory[]>(() => {
if (!selectedParentId.value) return [];
return props.categories.filter((category) => {
return category.parent?.id === selectedParentId.value;
});
});
const hasMultipleChildCategories = computed(() => {
return childCategories.value.length > 1;
});
const selectedCategory = computed<MenuCategory | undefined>(() => {
return childCategories.value.find((category) => {
return category.title === selectedCategoryTitle.value;
});
});
const currentItems = computed<MenuItem[]>(() => {
return selectedCategory.value?.items ?? [];
});
const currentStep = computed<Step>(() => {
if (!selectedParentId.value) return "parents";
if (hasMultipleChildCategories.value && !selectedCategoryTitle.value) {
return "categories";
}
return "items";
});
function getImageSrc(image?: Image) {
return image?.src || props.fallbackImage;
}
function getImageAlt(image: Image | undefined, fallback: string) {
return image?.alt || fallback;
}
function selectParent(parentId: number) {
selectedParentId.value = parentId;
const children = props.categories.filter((category) => {
return category.parent?.id === parentId;
});
if (children.length === 1) {
selectedCategoryTitle.value = children[0].title;
return;
}
selectedCategoryTitle.value = null;
}
function selectCategory(title: string) {
selectedCategoryTitle.value = title;
}
function goHome() {
emit("back");
}
function goMenuRoot() {
selectedParentId.value = null;
selectedCategoryTitle.value = null;
}
function goToParent() {
if (!selectedParentId.value) return;
if (!hasMultipleChildCategories.value) {
goMenuRoot();
return;
}
selectedCategoryTitle.value = null;
}
function setFallbackImage(event: Event) {
const image = event.target as HTMLImageElement;
image.src = props.fallbackImage;
}
watch([selectedParentId, selectedCategoryTitle], () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
</script>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>