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
+33
View File
@@ -0,0 +1,33 @@
import { onMounted, onBeforeUnmount } from "vue";
import type { Ref } from "vue";
/**
* useClickOutside composable:
* - Detects clicks outside of a given element reference.
* - Executes a callback when a click occurs outside the target.
* - Automatically registers and cleans up listeners on mount/unmount.
*/
export function useClickOutside(
elRef: Ref<HTMLElement | null>,
callback: (event: MouseEvent) => void
): void {
if (!elRef) return;
// Event handler: invokes callback when click is outside the element
const handleClick = (event: MouseEvent) => {
if (elRef.value && !elRef.value.contains(event.target as Node)) {
callback(event);
}
};
// Register click listener on document when component mounts
onMounted(() => {
document.addEventListener("click", handleClick);
});
// Remove click listener when component unmounts
onBeforeUnmount(() => {
document.removeEventListener("click", handleClick);
});
}
+51
View File
@@ -0,0 +1,51 @@
import { h, type Component } from "vue";
import { Icon } from "@iconify/vue";
export const iconNames = {
mail: "heroicons-solid:envelope",
document: "heroicons-solid:document-text",
code: "heroicons-solid:code-bracket",
chevronRightIcon: "heroicons-solid:chevron-right",
chevronLeftIcon: "heroicons-solid:chevron-left",
arrowRight: "heroicons-solid:arrow-right",
arrowLeft: "heroicons-solid:arrow-left",
arrowUp: "heroicons-solid:arrow-up",
external: "heroicons-solid:arrow-top-right-on-square",
phone: "heroicons-solid:phone",
location: "heroicons-solid:map-pin",
check: "heroicons-solid:check-circle",
plus: "heroicons-solid:plus",
user: "heroicons-solid:user",
home: "mdi:home",
share: "heroicons-solid:share",
clipboard: "heroicons-solid:clipboard-document",
link: "heroicons-solid:link",
chat: "heroicons-solid:chat-bubble-left-right",
close: "heroicons-solid:x-mark",
instagram: "mdi:instagram",
facebook: "mdi:facebook",
tiktok: "ic:baseline-tiktok",
} as const;
export type IconName = keyof typeof iconNames;
export function useIcons(): Record<IconName, Component> {
const registry = {} as Record<IconName, Component>;
for (const name of Object.keys(iconNames) as IconName[]) {
registry[name] = {
name: `${name}Icon`,
render() {
return h(Icon, {
icon: iconNames[name],
});
},
};
}
return registry;
}
+17
View File
@@ -0,0 +1,17 @@
// src/composables/useLocale.ts
import { useI18n } from "vue-i18n";
import { computed } from "vue";
/**
* useLocale composable:
* - Provides the current locale from vue-i18n.
* - Returns a computed `path` that prefixes URLs with /en for English locale.
* - Helps route components generate localized links.
*/
// Return localized path and current locale reference
export function useLocale() {
const { locale } = useI18n();
const path = computed(() => (locale.value === "en" ? "/en/" : "/"));
return { path };
}
+120
View File
@@ -0,0 +1,120 @@
import { useHead } from '@vueuse/head'
import { computed, unref } from 'vue'
/**
* useMeta composable:
* - Dynamically sets page meta tags and title using @vueuse/head.
* - Supports Open Graph, Twitter Card, and standard SEO metadata.
* - Localizes `og:locale` automatically based on current locale.
*/
// Interface for supported meta tag fields
interface MetaData {
title?: string
description?: string
keywords?: string[]
image?: string
type?: string
author?: string
canonical?: string
robots?: string
structuredData?: Record<string, any>
['og:locale']?: string
['og:site_name']?: string
['twitter:card']?: string
['twitter:creator']?: string
['twitter:title']?: string
['twitter:description']?: string
['twitter:image']?: string
}
export function useMeta(data: MetaData | any, locale: string | any) {
const d = () => unref(data) as MetaData
const loc = () => unref(locale) as string
const withHost = (u?: string) => {
if (!u) return undefined
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return 'https:' + u
const base = 'https://www.mucomutfak.com'
if (u.startsWith('/')) return base + u
return `${base}/${u}`
}
const ogLocale = computed(() => {
return d()['og:locale'] || (loc() === 'tr' ? 'tr_TR' : 'en_US')
})
const canonical = computed(() => {
return withHost(d().canonical)
})
const structuredData = computed(() => {
const schema = d().structuredData
if (!schema) return undefined
return JSON.stringify({
...schema,
url: withHost(schema.url),
image: withHost(schema.image),
})
})
useHead({
title: computed(() => d().title ?? '') as any,
htmlAttrs: {
lang: computed(() => loc() || 'en'),
},
meta: [
{ name: 'description', content: computed(() => d().description) },
{
name: 'keywords',
content: computed(() =>
d().keywords?.length ? d().keywords!.join(', ') : undefined,
),
},
{ name: 'author', content: computed(() => d().author) },
{ name: 'robots', content: computed(() => d().robots) },
{ property: 'og:title', content: computed(() => d().title) },
{ property: 'og:description', content: computed(() => d().description) },
{ property: 'og:image', content: computed(() => withHost(d().image)) },
{ property: 'og:url', content: canonical },
{ property: 'og:type', content: computed(() => d().type) },
{ property: 'og:locale', content: ogLocale },
{ property: 'og:site_name', content: computed(() => d()['og:site_name']) },
{ name: 'twitter:card', content: computed(() => d()['twitter:card']) },
{ name: 'twitter:creator', content: computed(() => d()['twitter:creator']) },
{ name: 'twitter:title', content: computed(() => d()['twitter:title'] || d().title) },
{
name: 'twitter:description',
content: computed(() => d()['twitter:description'] || d().description),
},
{
name: 'twitter:image',
content: computed(() => withHost(d()['twitter:image'] || d().image)),
},
],
link: [
{ rel: 'canonical', href: canonical },
],
script: computed(() =>
structuredData.value
? [
{
type: 'application/ld+json',
children: structuredData.value,
},
]
: [],
) as any,
})
}
+43
View File
@@ -0,0 +1,43 @@
import { shallowRef } from "vue";
/**
* usePageData composable:
* - Loads localized JSON content that was eagerly bundled at build time.
* - If the JSON is an array and a `slug` is provided, returns the matching item.
* - Otherwise returns the whole JSON payload.
* - Exposes the result as a shallowRef for lightweight reactivity.
*/
// Preload JSON files eagerly (SSG-safe)
const jsonModules = import.meta.glob("../data/**/*.json", { eager: true });
// Fetch page data for a locale; optionally filter by slug
export function usePageData<T>(page: string, locale: string, slug?: string) {
// Result holder (reactive)
const data = shallowRef<T | null>(null);
// Build module path for the requested page and locale
const path = `../data/${page}/${locale}.json`;
// Look up the eagerly-imported JSON module
const module = jsonModules[path] as any;
// Module found: extract its default export
if (module?.default) {
// JSON payload (object or array)
const payload = module?.default ?? null;
// If a slug is provided and payload is a list, return the matching item
if (slug && Array.isArray(payload)) {
// Find item with matching slug
const found =
(payload as any[]).find((item: any) => item && item.slug === slug) ??
null;
data.value = (found as T) ?? null;
} else {
data.value = payload as T;
}
}
// Module missing: warn and set null
else {
console.warn(`[usePageData] Missing data for ${page}/${locale}.json`);
data.value = null;
}
// Return the shallowRef so consumers can watch/react to it
return data;
}