Ajout de la section galerie photo et nettoyage du template
Galerie photo : - Ajout du layout photo avec slideshow plein écran - Navigation par catégories (portraits, paysages, nature, etc.) - Section "Fil Photo" avec posts illustrés (photoBlogPosts) - Lightbox pour les albums de catégories - Composants : Slideshow, CategoryNav, CategoryGrid, Lightbox, MasonryGallery Nettoyage : - Suppression du contenu démo du template (posts, images, about) - Consolidation src/collections/ dans src/data/ - Suppression du config.js dupliqué (garde config.ts) - Nettoyage des assets inutilisés (posts/, experiences/) Corrections : - Favicon récupéré du site actuel - Chemins favicon corrigés dans les layouts UI : - Page d'accueil mise à jour - Header/Footer simplifiés - Nouvelle page À propos
This commit is contained in:
parent
be8b09ee55
commit
dc3fb4f3d8
88 changed files with 4969 additions and 1024 deletions
4
.env.example
Normal file
4
.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
WEBDAV_URL=https://nas.arfaoui.net:6006
|
||||
WEBDAV_PATH=/photo/Portfolio
|
||||
WEBDAV_USER=your_username
|
||||
WEBDAV_PASS=your_password
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,6 +17,9 @@ pnpm-debug.log*
|
|||
.env
|
||||
.env.production
|
||||
|
||||
# images fetched from NAS
|
||||
src/assets/images/photos/
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
|
|
|
|||
|
|
@ -5,4 +5,11 @@ import tailwind from "@astrojs/tailwind";
|
|||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
i18n: {
|
||||
defaultLocale: "fr",
|
||||
locales: ["fr", "en", "ar"],
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
850
package-lock.json
generated
850
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,8 @@
|
|||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"fetch-images": "tsx scripts/fetch-images.ts",
|
||||
"prebuild": "npm run fetch-images",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
|
|
@ -15,9 +17,13 @@
|
|||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@biomejs/biome": "1.7.3",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"astro": "^4.8.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5"
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.4.5",
|
||||
"webdav": "^5.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.2",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.3 KiB |
87
scripts/fetch-images.ts
Normal file
87
scripts/fetch-images.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import "dotenv/config";
|
||||
import { createClient } from "webdav";
|
||||
import { mkdir, writeFile, stat } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
interface FileStat {
|
||||
filename: string;
|
||||
basename: string;
|
||||
type: "file" | "directory";
|
||||
size: number;
|
||||
lastmod: string;
|
||||
}
|
||||
|
||||
const WEBDAV_URL = process.env.WEBDAV_URL || "https://nas.arfaoui.net:6006";
|
||||
const WEBDAV_PATH = process.env.WEBDAV_PATH || "/photo/Portfolio";
|
||||
const WEBDAV_USER = process.env.WEBDAV_USER;
|
||||
const WEBDAV_PASS = process.env.WEBDAV_PASS;
|
||||
const DEST_DIR = "src/assets/images/photos";
|
||||
|
||||
async function main() {
|
||||
if (!WEBDAV_USER || !WEBDAV_PASS) {
|
||||
console.error("Error: WEBDAV_USER and WEBDAV_PASS environment variables are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createClient(WEBDAV_URL, {
|
||||
username: WEBDAV_USER,
|
||||
password: WEBDAV_PASS,
|
||||
});
|
||||
|
||||
console.log(`Fetching images from ${WEBDAV_URL}${WEBDAV_PATH}...`);
|
||||
|
||||
await syncDirectory(client, WEBDAV_PATH, DEST_DIR);
|
||||
|
||||
console.log("Done!");
|
||||
}
|
||||
|
||||
async function syncDirectory(client: ReturnType<typeof createClient>, remotePath: string, localPath: string) {
|
||||
await mkdir(localPath, { recursive: true });
|
||||
|
||||
const items = (await client.getDirectoryContents(remotePath)) as FileStat[];
|
||||
|
||||
for (const item of items) {
|
||||
const localItemPath = join(localPath, item.basename);
|
||||
const remoteItemPath = item.filename;
|
||||
|
||||
if (item.type === "directory") {
|
||||
console.log(` [dir] ${item.basename}/`);
|
||||
await syncDirectory(client, remoteItemPath, localItemPath);
|
||||
} else if (item.type === "file" && /\.(jpg|jpeg|png|webp)$/i.test(item.basename)) {
|
||||
const needsDownload = await shouldDownload(localItemPath, item);
|
||||
|
||||
if (needsDownload) {
|
||||
console.log(` [download] ${remoteItemPath}`);
|
||||
const content = (await client.getFileContents(remoteItemPath)) as Buffer;
|
||||
await mkdir(dirname(localItemPath), { recursive: true });
|
||||
await writeFile(localItemPath, content);
|
||||
} else {
|
||||
console.log(` [skip] ${item.basename} (unchanged)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldDownload(localPath: string, remoteItem: FileStat): Promise<boolean> {
|
||||
try {
|
||||
const localStat = await stat(localPath);
|
||||
const remoteSize = remoteItem.size;
|
||||
const localSize = localStat.size;
|
||||
|
||||
if (remoteSize !== localSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const remoteDate = new Date(remoteItem.lastmod).getTime();
|
||||
const localDate = localStat.mtime.getTime();
|
||||
|
||||
return remoteDate > localDate;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[
|
||||
{
|
||||
"dates": "June 2018 · Present",
|
||||
"role": "Front-end Engineer",
|
||||
"company": "Full Truck Alliance",
|
||||
"description": "Responsible for customer service and CRM system front-end development.",
|
||||
"logo": "/assets/images/experiences/fta.ico"
|
||||
},
|
||||
{
|
||||
"dates": "July 2015 · June 2018",
|
||||
"role": "Front-end Engineer",
|
||||
"company": "YOHO!",
|
||||
"description": "Responsible for mobile front-end development of e-commerce platform.",
|
||||
"logo": "/assets/images/experiences/yoho.ico"
|
||||
},
|
||||
{
|
||||
"dates": "September 2014 · July 2015 ",
|
||||
"role": "Node.JS Developer",
|
||||
"company": "WuLian",
|
||||
"description": "Intern, involved in the development of Internet of Things cloud systems.",
|
||||
"logo": "/assets/images/experiences/wulian.ico"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
[
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "/"
|
||||
},
|
||||
{
|
||||
"name": "Posts",
|
||||
"url": "/posts"
|
||||
},
|
||||
{
|
||||
"name": "Projects",
|
||||
"url": "/projects"
|
||||
},
|
||||
{
|
||||
"name": "About",
|
||||
"url": "/about"
|
||||
}
|
||||
]
|
||||
55
src/components/DarkModeToggle.astro
Normal file
55
src/components/DarkModeToggle.astro
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
// DarkModeToggle component - toggles between light and dark mode
|
||||
---
|
||||
|
||||
<button
|
||||
id="darkModeToggle"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 dark:hidden"
|
||||
id="sunIcon"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
class="hidden w-5 h-5 dark:block"
|
||||
id="moonIcon"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function setupDarkModeToggle() {
|
||||
const toggle = document.getElementById('darkModeToggle');
|
||||
|
||||
toggle?.addEventListener('click', () => {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
}
|
||||
|
||||
// Run on page load and on Astro navigation
|
||||
setupDarkModeToggle();
|
||||
document.addEventListener('astro:after-swap', setupDarkModeToggle);
|
||||
</script>
|
||||
75
src/components/LanguageSwitcher.astro
Normal file
75
src/components/LanguageSwitcher.astro
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
// Mapping des URLs entre langues
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
'/a-propos': {
|
||||
fr: '/a-propos',
|
||||
en: '/en/about',
|
||||
ar: '/ar/نبذة-عني'
|
||||
},
|
||||
'/en/about': {
|
||||
fr: '/a-propos',
|
||||
en: '/en/about',
|
||||
ar: '/ar/نبذة-عني'
|
||||
},
|
||||
'/ar/نبذة-عني': {
|
||||
fr: '/a-propos',
|
||||
en: '/en/about',
|
||||
ar: '/ar/نبذة-عني'
|
||||
},
|
||||
// Page d'accueil
|
||||
'/': {
|
||||
fr: '/',
|
||||
en: '/en',
|
||||
ar: '/ar'
|
||||
},
|
||||
'/en': {
|
||||
fr: '/',
|
||||
en: '/en',
|
||||
ar: '/ar'
|
||||
},
|
||||
'/ar': {
|
||||
fr: '/',
|
||||
en: '/en',
|
||||
ar: '/ar'
|
||||
}
|
||||
};
|
||||
|
||||
// Détection de la langue courante
|
||||
const pathname = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const currentLang = pathname.startsWith('/en') ? 'en' : pathname.startsWith('/ar') ? 'ar' : 'fr';
|
||||
|
||||
// Récupération des liens traduits ou fallback vers les pages d'accueil
|
||||
const links = translations[pathname] || {
|
||||
fr: '/',
|
||||
en: '/en',
|
||||
ar: '/ar'
|
||||
};
|
||||
|
||||
const languages = [
|
||||
{ code: 'fr', label: 'FR', name: 'Français' },
|
||||
{ code: 'en', label: 'EN', name: 'English' },
|
||||
{ code: 'ar', label: 'ع', name: 'العربية' }
|
||||
];
|
||||
---
|
||||
|
||||
<div class="language-switcher flex items-center gap-1 text-sm">
|
||||
{languages.map((lang, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="text-gray-400 dark:text-neutral-600">·</span>}
|
||||
{lang.code === currentLang ? (
|
||||
<span class="font-semibold text-gray-800 dark:text-neutral-200" title={lang.name}>
|
||||
{lang.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={links[lang.code]}
|
||||
class="text-gray-500 dark:text-neutral-400 hover:text-gray-800 dark:hover:text-neutral-200 transition-colors"
|
||||
title={lang.name}
|
||||
hreflang={lang.code}
|
||||
>
|
||||
{lang.label}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
16
src/components/Link.astro
Normal file
16
src/components/Link.astro
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
interface Props {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { href, external = false, class: className = '' } = Astro.props;
|
||||
|
||||
const baseClasses = 'text-indigo-600 dark:text-indigo-400 hover:underline';
|
||||
const classes = `${baseClasses} ${className}`.trim();
|
||||
|
||||
const externalProps = external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
---
|
||||
|
||||
<a href={href} class={classes} {...externalProps}><slot /></a>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
---
|
||||
import Logo from "../components/logo.astro";
|
||||
import LanguageSwitcher from "../components/LanguageSwitcher.astro";
|
||||
import DarkModeToggle from "../components/DarkModeToggle.astro";
|
||||
---
|
||||
|
||||
<section
|
||||
|
|
@ -12,69 +14,74 @@ import Logo from "../components/logo.astro";
|
|||
<p
|
||||
class="mt-4 text-sm text-neutral-700 dark:text-neutral-100 sm:ml-4 sm:pl-4 sm:border-l sm:border-neutral-300 dark:sm:border-neutral-700 sm:mt-0"
|
||||
>
|
||||
© {new Date().getFullYear()} Aria
|
||||
© {new Date().getFullYear()} Jalil Arfaoui
|
||||
</p>
|
||||
<span
|
||||
class="inline-flex justify-center mt-4 space-x-5 sm:ml-auto sm:mt-0 sm:justify-start"
|
||||
>
|
||||
|
||||
<a
|
||||
href="https://instagram.com/ccbikai"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
<span class="sr-only">Instagram</span>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
|
||||
clip-rule="evenodd"></path></svg
|
||||
<div class="flex items-center gap-6 mt-4 sm:ml-auto sm:mt-0">
|
||||
<LanguageSwitcher />
|
||||
<DarkModeToggle />
|
||||
|
||||
<!-- Social Links -->
|
||||
<span class="inline-flex items-center space-x-4">
|
||||
<a
|
||||
href="https://www.instagram.com/l.i.l.a.j"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
title="Instagram"
|
||||
>
|
||||
</a>
|
||||
<span class="sr-only">Instagram</span>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://twitter.com/0xKaiBi"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
<span class="sr-only">𝕏</span>
|
||||
<svg
|
||||
class="w-6 h-6 dark:stroke-black stroke-white"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M95 50c0 24.853-20.147 45-45 45S5 74.853 5 50 25.147 5 50 5s45 20.147 45 45Zm-51.21 2.688-21.51-28.76h16.578l14.1 18.855 17.453-18.855h4.872L55.135 45.694l22.72 30.377H61.279L45.967 55.598l-18.95 20.473h-4.873L43.79 52.688Zm-6.73-25.172h-7.616l33.63 44.967h7.616L37.06 27.516Z"
|
||||
fill="currentColor"></path><path
|
||||
d="M22.28 23.928v-.5h-.998l.597.8.4-.3Zm21.51 28.76.366.34.283-.306-.25-.334-.4.3Zm-4.932-28.76.4-.3-.15-.2h-.25v.5Zm14.1 18.855-.4.3.36.48.408-.44-.367-.34Zm17.453-18.855v-.5h-.219l-.148.16.367.34Zm4.872 0 .367.34.777-.84h-1.144v.5ZM55.135 45.694l-.367-.34-.282.306.249.333.4-.3Zm22.72 30.377v.5h.999l-.598-.8-.4.3Zm-16.577 0-.4.3.15.2h.25v-.5ZM45.967 55.598l.4-.3-.36-.48-.407.44.367.34Zm-18.95 20.473v.5h.218l.148-.16-.367-.34Zm-4.873 0-.367-.34-.777.84h1.144v-.5Zm7.3-48.554v-.5h-.998l.598.799.4-.3Zm7.616 0 .4-.3-.15-.2h-.25v.5Zm26.015 44.966-.4.3.15.2h.25v-.5Zm7.615 0v.5h.999l-.598-.8-.4.3ZM50 95.5c25.129 0 45.5-20.371 45.5-45.5h-1c0 24.577-19.923 44.5-44.5 44.5v1ZM4.5 50c0 25.129 20.371 45.5 45.5 45.5v-1C25.423 94.5 5.5 74.577 5.5 50h-1ZM50 4.5C24.871 4.5 4.5 24.871 4.5 50h1C5.5 25.423 25.423 5.5 50 5.5v-1ZM95.5 50C95.5 24.871 75.129 4.5 50 4.5v1c24.577 0 44.5 19.923 44.5 44.5h1ZM21.88 24.228l21.509 28.76.8-.6-21.509-28.76-.8.6Zm16.978-.8H22.28v1h16.578v-1Zm14.501 19.055L39.258 23.63l-.8.599 14.1 18.854.801-.599ZM70.044 23.59 52.592 42.443l.734.68 17.452-18.855-.734-.68Zm5.239-.16H70.41v1h4.872v-1Zm-19.78 22.605L75.65 24.268l-.734-.68-20.148 21.766.734.68Zm22.753 29.738-22.72-30.378-.801.6 22.72 30.377.8-.6Zm-16.978.799h16.578v-1H61.278v1ZM45.566 55.898 60.877 76.37l.801-.599L46.368 55.3l-.802.599ZM27.383 76.41l18.95-20.473-.733-.68-18.95 20.473.733.68Zm-5.239.16h4.872v-1h-4.872v1Zm21.278-24.223L21.777 75.731l.734.68 21.645-23.383-.734-.68ZM29.444 28.017h7.616v-1h-7.616v1Zm34.031 44.166-33.63-44.966-.801.599 33.63 44.966.801-.599Zm7.215-.2h-7.615v1h7.615v-1ZM36.66 27.816l33.63 44.966.8-.599-33.63-44.966-.8.599Z"
|
||||
fill="currentStroke"></path></svg
|
||||
<a
|
||||
href="https://www.linkedin.com/in/jalil"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
title="LinkedIn"
|
||||
>
|
||||
</a>
|
||||
<span class="sr-only">LinkedIn</span>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/ccbikai"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
<span class="sr-only">GitHub</span>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clip-rule="evenodd"></path></svg
|
||||
<a
|
||||
href="https://github.com/jalilarfaoui"
|
||||
target="_blank"
|
||||
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||
title="GitHub"
|
||||
>
|
||||
</a>
|
||||
|
||||
</span>
|
||||
<span class="sr-only">GitHub</span>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
211
src/components/header-i18n.astro
Normal file
211
src/components/header-i18n.astro
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
---
|
||||
import { mainMenu } from "../data/menu";
|
||||
import Logo from "../components/logo.astro";
|
||||
import { getLocaleFromUrl } from "../utils/i18n";
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const locale = getLocaleFromUrl(Astro.url) || 'fr';
|
||||
|
||||
const languageOptions = [
|
||||
{ code: 'fr', label: 'FR', flag: '🇫🇷' },
|
||||
{ code: 'en', label: 'EN', flag: '🇬🇧' },
|
||||
{ code: 'ar', label: 'AR', flag: '🇸🇦', dir: 'rtl' }
|
||||
];
|
||||
|
||||
function getLocalizedUrl(url: string, targetLocale: string): string {
|
||||
if (targetLocale === 'fr') {
|
||||
return url;
|
||||
}
|
||||
return `/${targetLocale}${url}`;
|
||||
}
|
||||
|
||||
function getCurrentLocaleUrl(targetLocale: string): string {
|
||||
let path = currentPath;
|
||||
|
||||
if (locale !== 'fr') {
|
||||
path = path.replace(`/${locale}`, '');
|
||||
}
|
||||
|
||||
if (targetLocale === 'fr') {
|
||||
return path || '/';
|
||||
}
|
||||
|
||||
return `/${targetLocale}${path || '/'}`;
|
||||
}
|
||||
---
|
||||
|
||||
<div class="relative w-full h-20 opacity-0 pointer-events-none"></div>
|
||||
<header id="header" class="absolute top-0 z-50 w-full h-20">
|
||||
<div
|
||||
class="flex items-center justify-between h-full max-w-6xl pl-6 pr-4 mx-auto border-b border-l-0 border-r-0 border-transparent select-none lg:border-r lg:border-l lg:rounded-b-xl"
|
||||
>
|
||||
<Logo />
|
||||
<div
|
||||
id="mobileMenuBackground"
|
||||
onclick="closeMobileMenu()"
|
||||
class="fixed inset-0 z-20 hidden w-screen h-screen duration-300 ease-out bg-white/90 dark:bg-neutral-950/90"
|
||||
>
|
||||
</div>
|
||||
<nav
|
||||
class="relative z-30 flex flex-row-reverse justify-start w-full text-sm sm:justify-end text-neutral-500 dark:text-neutral-400 sm:flex-row"
|
||||
>
|
||||
<div
|
||||
id="openMenu"
|
||||
class="flex flex-col items-end justify-center w-6 h-6 ml-4 cursor-pointer sm:hidden"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 dark:text-neutral-200"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"><path d="M4 8h16M4 16h16"></path></svg
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="closeMenu"
|
||||
class="flex flex-col items-end justify-center hidden w-6 h-6 ml-4 -translate-x-1 cursor-pointer sm:hidden"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-neutral-600 dark:text-neutral-200"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"></path></svg
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="menu"
|
||||
class="fixed top-[75px] ease-out duration-300 sm:top-0 w-full left-0 sm:py-0 pt-7 pb-4 dm:mx-0 left-0 z-40 flex-col items-end justify-start hidden w-full h-auto text-sm sm:text-base sm:h-auto sm:relative sm:flex-row sm:flex sm:text-sm sm:w-auto sm:pr-0 sm:pt-0"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 top-0 right-0 block w-full h-full px-3 sm:hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full h-full bg-white border border-dashed border-neutral-300 dark:border-neutral-700 backdrop-blur-sm rounded-xl dark:bg-neutral-950"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
mainMenu.map((menu) => {
|
||||
const hasChildren = menu.children && menu.children.length > 0;
|
||||
const menuUrl = locale === 'fr' ? menu.url : `/${locale}${menu.url}`;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div class="relative group">
|
||||
<a
|
||||
href={menuUrl}
|
||||
class="relative flex items-center justify-center w-full px-3 py-2 font-medium tracking-wide text-center duration-200 ease-out sm:py-0 sm:mb-0 md:w-auto hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
{menu.name[locale]}
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="absolute left-0 hidden mt-2 bg-white dark:bg-neutral-900 rounded-lg shadow-lg group-hover:block">
|
||||
<div class="py-2">
|
||||
{menu.children.map((child) => {
|
||||
const childUrl = locale === 'fr' ? child.url : `/${locale}${child.url}`;
|
||||
return (
|
||||
<a
|
||||
href={childUrl}
|
||||
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 whitespace-nowrap"
|
||||
>
|
||||
{child.name[locale]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={menuUrl}
|
||||
class="relative flex items-center justify-center w-full px-3 py-2 font-medium tracking-wide text-center duration-200 ease-out sm:py-0 sm:mb-0 md:w-auto hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
{menu.name[locale]}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<div class="relative ml-4 group">
|
||||
<button class="flex items-center px-2 py-1 text-sm font-medium rounded hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<span class="mr-1">{languageOptions.find(l => l.code === locale)?.flag}</span>
|
||||
{languageOptions.find(l => l.code === locale)?.label}
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute right-0 hidden mt-2 bg-white dark:bg-neutral-900 rounded-lg shadow-lg group-hover:block">
|
||||
<div class="py-2">
|
||||
{languageOptions.map((lang) => (
|
||||
<a
|
||||
href={getCurrentLocaleUrl(lang.code)}
|
||||
class="flex items-center px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
dir={lang.dir}
|
||||
>
|
||||
<span class="mr-2">{lang.flag}</span>
|
||||
{lang.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<div
|
||||
id="darkToggle"
|
||||
class="relative flex items-center pl-6 ml-4 font-medium tracking-wide cursor-pointer text-neutral-800 group dark:text-white"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 flex items-center justify-center w-6 h-6 overflow-hidden border-b border-transparent horizon group-hover:border-neutral-600"
|
||||
>
|
||||
<svg
|
||||
class="absolute w-6 h-6 transition duration-700 transform ease"
|
||||
id="sun"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path></svg
|
||||
>
|
||||
<svg
|
||||
class="absolute hidden w-6 h-6 transition duration-700 transform ease"
|
||||
id="moon"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
></path></svg
|
||||
>
|
||||
</div>
|
||||
<span class="hidden sm:inline-block">
|
||||
<span id="dayText" class="ml-2">
|
||||
{locale === 'fr' ? 'Jour' : locale === 'en' ? 'Day' : 'نهار'}
|
||||
</span>
|
||||
<span id="nightText" class="hidden ml-2">
|
||||
{locale === 'fr' ? 'Nuit' : locale === 'en' ? 'Night' : 'ليل'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -1,6 +1,27 @@
|
|||
---
|
||||
import menus from "../collections/menu.json";
|
||||
import Logo from "../components/logo.astro";
|
||||
|
||||
// Détection de la langue courante
|
||||
const pathname = Astro.url.pathname;
|
||||
const currentLang = pathname.startsWith('/en') ? 'en' : pathname.startsWith('/ar') ? 'ar' : 'fr';
|
||||
|
||||
// Menu localisé
|
||||
const menus = {
|
||||
fr: [
|
||||
{ name: 'Photo', url: '/photo' },
|
||||
{ name: 'À propos', url: '/a-propos' }
|
||||
],
|
||||
en: [
|
||||
{ name: 'Photo', url: '/photo' },
|
||||
{ name: 'About', url: '/en/about' }
|
||||
],
|
||||
ar: [
|
||||
{ name: 'صور', url: '/photo' },
|
||||
{ name: 'نبذة عني', url: '/ar/نبذة-عني' }
|
||||
]
|
||||
};
|
||||
|
||||
const currentMenus = menus[currentLang] || menus.fr;
|
||||
---
|
||||
|
||||
<!-- This is an invisible div with relative position so that it takes up the height of the menu (because menu is absolute/fixed) -->
|
||||
|
|
@ -60,57 +81,16 @@ import Logo from "../components/logo.astro";
|
|||
</div>
|
||||
</div>
|
||||
{
|
||||
menus.map((menu) => {
|
||||
return (
|
||||
<a
|
||||
href={menu.url}
|
||||
class="relative flex items-center justify-center w-full px-3 py-2 font-medium tracking-wide text-center duration-200 ease-out sm:py-0 sm:mb-0 md:w-auto hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
{menu.name}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
currentMenus.map((menu) => (
|
||||
<a
|
||||
href={menu.url}
|
||||
class="relative flex items-center justify-center w-full px-3 py-2 font-medium tracking-wide text-center duration-200 ease-out sm:py-0 sm:mb-0 md:w-auto hover:text-neutral-900 dark:hover:text-white"
|
||||
>
|
||||
{menu.name}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
id="darkToggle"
|
||||
class="relative flex items-center pl-6 ml-4 font-medium tracking-wide cursor-pointer text-neutral-800 group dark:text-white"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 flex items-center justify-center w-6 h-6 overflow-hidden border-b border-transparent horizon group-hover:border-neutral-600"
|
||||
>
|
||||
<svg
|
||||
class="absolute w-6 h-6 transition duration-700 transform ease"
|
||||
id="sun"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path></svg
|
||||
>
|
||||
<svg
|
||||
class="absolute hidden w-6 h-6 transition duration-700 transform ease"
|
||||
id="moon"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
></path></svg
|
||||
>
|
||||
</div>
|
||||
<span class="hidden sm:inline-block">
|
||||
<span id="dayText" class="ml-2">Day mode</span>
|
||||
<span id="nightText" class="hidden ml-2">Night mode</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import projects from "../../collections/projects.json";
|
||||
import projects from "../../data/projects.json";
|
||||
import Button from "../button.astro";
|
||||
import Project from "../project.astro";
|
||||
---
|
||||
|
|
|
|||
23
src/components/icons/HomeIcon.astro
Normal file
23
src/components/icons/HomeIcon.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
interface Props {
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { size = 20, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9,22 9,12 15,12 15,22"/>
|
||||
</svg>
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
<a
|
||||
href="/"
|
||||
class="h-5 text-base group relative z-30 flex items-center space-x-1.5 text-black dark:text-white font-semibold"
|
||||
>
|
||||
<span
|
||||
class="text-xl -translate-y-0.5 group-hover:-rotate-12 group-hover:scale-[1.2] ease-in-out duration-300"
|
||||
>✦</span
|
||||
---
|
||||
import HomeIcon from "./icons/HomeIcon.astro";
|
||||
|
||||
const pathname = Astro.url.pathname;
|
||||
const isHome = pathname === '/' || pathname === '/en' || pathname === '/en/' || pathname === '/ar' || pathname === '/ar/';
|
||||
---
|
||||
|
||||
{!isHome && (
|
||||
<a
|
||||
href="/"
|
||||
class="group relative z-30 flex items-center text-neutral-600 dark:text-neutral-300 hover:text-black dark:hover:text-white transition-colors"
|
||||
title="Accueil"
|
||||
>
|
||||
<!-- Logo Text -->
|
||||
<span class="-translate-y-0.5"> aria</span>
|
||||
</a>
|
||||
<HomeIcon size={20} class="group-hover:scale-110 transition-transform duration-200" />
|
||||
</a>
|
||||
)}
|
||||
|
|
|
|||
155
src/components/photo/AlbumHeader.astro
Normal file
155
src/components/photo/AlbumHeader.astro
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import ScrollIndicator from './ScrollIndicator.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
date?: Date;
|
||||
tags?: string[];
|
||||
coverImage?: ImageMetadata;
|
||||
scrollTarget?: string;
|
||||
}
|
||||
|
||||
const { title, description, date, tags, coverImage, scrollTarget = '.info-section' } = Astro.props;
|
||||
---
|
||||
|
||||
{coverImage && (
|
||||
<div class="hero-image">
|
||||
<Image src={coverImage} alt={title} widths={[800, 1200, 1920]} formats={['webp', 'avif']} />
|
||||
<div class="hero-overlay">
|
||||
<div class="hero-content">
|
||||
<h1 class="album-title">{title}</h1>
|
||||
{description && <p class="album-description">{description}</p>}
|
||||
{date && (
|
||||
<time class="album-date">
|
||||
{date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
<ScrollIndicator targetSelector={scrollTarget} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<div class="info-section">
|
||||
<div class="album-tags">
|
||||
{tags.map(tag => (
|
||||
<span class="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height, 53px) - var(--footer-height, 54px));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 64px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -2px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.album-description {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
margin: 0 auto;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
max-width: 600px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.album-date {
|
||||
font-size: 16px;
|
||||
margin-top: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.album-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.album-title {
|
||||
font-size: 36px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.album-description {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.album-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
307
src/components/photo/CategoryGrid.astro
Normal file
307
src/components/photo/CategoryGrid.astro
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
---
|
||||
import CategoryNav from './CategoryNav.astro';
|
||||
import ScrollIndicator from './ScrollIndicator.astro';
|
||||
import Lightbox from './Lightbox.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
const { category } = Astro.props;
|
||||
|
||||
// Récupérer les métadonnées de la catégorie
|
||||
const categoryData = await getEntry('photoCategories', category);
|
||||
|
||||
// Auto-détection des images du dossier de la catégorie
|
||||
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/categories/**/*.{jpg,jpeg,png,webp}');
|
||||
|
||||
// Filtrer les images de cette catégorie
|
||||
const categoryPath = `/src/assets/images/photos/categories/${category}/`;
|
||||
const categoryImages = Object.entries(allImages)
|
||||
.filter(([path]) => path.startsWith(categoryPath))
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
// Résoudre les images et générer les métadonnées
|
||||
const images = await Promise.all(
|
||||
categoryImages.map(async ([path, loader]) => {
|
||||
const img = await loader();
|
||||
const filename = path.split('/').pop() || '';
|
||||
const title = filename
|
||||
.replace(/\.[^/.]+$/, '') // Enlever l'extension
|
||||
.replace(/-/g, ' ') // Remplacer les tirets par des espaces
|
||||
.replace(/_/g, ' '); // Remplacer les underscores par des espaces
|
||||
return {
|
||||
src: img.default,
|
||||
alt: title,
|
||||
title: title,
|
||||
path: path
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Données pour la lightbox
|
||||
const lightboxImages = images.map(img => ({
|
||||
src: img.src.src,
|
||||
alt: img.alt,
|
||||
title: img.title
|
||||
}));
|
||||
---
|
||||
|
||||
<div id="category-gallery" class="category-container">
|
||||
<CategoryNav currentCategory={category} opaque={true} />
|
||||
|
||||
<!-- Image hero avec titre en overlay -->
|
||||
<header class="hero-cover">
|
||||
<div class="hero-image">
|
||||
{images[0] && <Image src={images[0].src} alt={images[0].alt} widths={[800, 1200, 1920]} formats={['webp', 'avif']} class="hero-bg" />}
|
||||
<div class="hero-overlay">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<a href="#thumbnails" class="scroll-link">{categoryData?.data.title || category}</a>
|
||||
</h1>
|
||||
{categoryData?.data.subtitle && (
|
||||
<p class="hero-subtitle">{categoryData.data.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<ScrollIndicator targetSelector="#thumbnails" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Grille de thumbnails -->
|
||||
<div id="thumbnails" class="thumbnails-grid">
|
||||
{images.map((image, index) => (
|
||||
<div class="thumbnail-item">
|
||||
<a href="#" class="thumbnail-link" data-image-index={index}>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
widths={[300, 500, 800]}
|
||||
formats={['webp', 'avif']}
|
||||
class="thumbnail-image"
|
||||
loading={index < 12 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div class="thumbnail-overlay">
|
||||
<div class="overlay-content">
|
||||
<h3 class="thumbnail-title">{image.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Lightbox images={lightboxImages} showCategory={true} category={category} />
|
||||
|
||||
<script>
|
||||
let lastScrollY = window.scrollY;
|
||||
let ticking = false;
|
||||
|
||||
function updateNavVisibility() {
|
||||
const header = document.querySelector('.category-navigation');
|
||||
const footer = document.querySelector('footer');
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
if (scrollY > lastScrollY && scrollY > 100) {
|
||||
header?.style.setProperty('transform', 'translateY(-100%)');
|
||||
footer?.style.setProperty('transform', 'translateY(100%)');
|
||||
} else {
|
||||
header?.style.setProperty('transform', 'translateY(0)');
|
||||
footer?.style.setProperty('transform', 'translateY(0)');
|
||||
}
|
||||
|
||||
lastScrollY = scrollY;
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateNavVisibility);
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => { ticking = false; });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('scroll', onScroll);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--header-height: 53px;
|
||||
--footer-height: 54px;
|
||||
--scroll-arrow-space: 200px;
|
||||
--footer-clearance: 300px;
|
||||
--grid-padding-top: 40px;
|
||||
--grid-padding-horizontal: 20px;
|
||||
--grid-column-gap: 20px;
|
||||
--thumbnail-margin-bottom: 20px;
|
||||
--grid-columns-desktop: 5;
|
||||
--grid-columns-tablet: 3;
|
||||
--grid-columns-mobile: 2;
|
||||
--grid-columns-small: 1;
|
||||
}
|
||||
|
||||
.category-container {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height) - var(--footer-height));
|
||||
overflow: hidden;
|
||||
margin-top: var(--header-height);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-image :global(.hero-bg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.41);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 64px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.hero-title .scroll-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero-title .scroll-link:hover {
|
||||
color: #a8a8a8;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
max-width: 600px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.thumbnails-grid {
|
||||
columns: var(--grid-columns-desktop);
|
||||
column-gap: var(--grid-column-gap);
|
||||
padding: var(--grid-padding-top) var(--grid-padding-horizontal) var(--footer-clearance);
|
||||
margin: var(--scroll-arrow-space) auto 0;
|
||||
max-width: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.thumbnail-item {
|
||||
position: relative;
|
||||
break-inside: avoid;
|
||||
margin-bottom: var(--thumbnail-margin-bottom);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.thumbnail-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thumbnail-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.thumbnail-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.thumbnail-title {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.thumbnail-item:hover .thumbnail-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.thumbnails-grid {
|
||||
columns: var(--grid-columns-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.thumbnails-grid {
|
||||
columns: var(--grid-columns-mobile);
|
||||
padding: var(--grid-padding-top) 10px var(--footer-clearance);
|
||||
}
|
||||
|
||||
.thumbnail-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.thumbnails-grid {
|
||||
columns: var(--grid-columns-small);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/components/photo/CategoryNav.astro
Normal file
251
src/components/photo/CategoryNav.astro
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import HomeIcon from '../icons/HomeIcon.astro';
|
||||
|
||||
const { currentCategory = '', opaque = false } = Astro.props;
|
||||
|
||||
// Récupérer les catégories depuis la collection, triées par order
|
||||
const photoCategories = await getCollection('photoCategories');
|
||||
const sortedCategories = photoCategories.sort((a, b) => (a.data.order || 99) - (b.data.order || 99));
|
||||
|
||||
// Catégories photos uniquement
|
||||
const categories = sortedCategories.map(cat => ({ id: cat.id, title: cat.data.title }));
|
||||
---
|
||||
|
||||
<nav class={`category-navigation fixed top-0 left-0 right-0 z-50 ${opaque ? 'bg-black' : 'bg-black/30 backdrop-blur-sm'} transition-transform duration-300`}>
|
||||
<div class="nav-container">
|
||||
<!-- Titre du site à gauche -->
|
||||
<div class="site-title">
|
||||
<a href="/" class="site-link">
|
||||
<HomeIcon size={16} />
|
||||
<span class="site-name">Jalil Arfaoui</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Bouton hamburger (mobile) -->
|
||||
<button class="hamburger" id="menuToggle" aria-label="Menu" aria-expanded="false">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="nav-menu" id="navMenu">
|
||||
<a
|
||||
href="/photo/blog"
|
||||
class={`nav-link blog-link ${currentCategory === 'blog' ? 'active' : ''}`}
|
||||
>
|
||||
Fil Photo
|
||||
</a>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<span class="nav-separator"></span>
|
||||
|
||||
<!-- Catégories photos -->
|
||||
<ul class="category-list">
|
||||
{categories.map(cat => (
|
||||
<li>
|
||||
<a
|
||||
href={`/photo/albums/${cat.id}`}
|
||||
class={`nav-link ${currentCategory === cat.id ? 'active' : ''}`}
|
||||
data-category={cat.id}
|
||||
>
|
||||
{cat.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.nav-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
height: 53px;
|
||||
}
|
||||
|
||||
.site-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.site-link:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
padding-bottom: 2px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 900px) {
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 53px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(100vh - 53px);
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding-top: 40px;
|
||||
background: rgba(0, 0, 0, 0.98);
|
||||
backdrop-filter: blur(10px);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.nav-menu.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.nav-separator {
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.category-list li {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 15px 20px;
|
||||
font-size: 18px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Animation hamburger -> X */
|
||||
.hamburger.open .hamburger-line:nth-child(1) {
|
||||
transform: rotate(45deg) translate(5px, 5px);
|
||||
}
|
||||
|
||||
.hamburger.open .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger.open .hamburger-line:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(5px, -5px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.site-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuToggle = document.getElementById('menuToggle');
|
||||
const navMenu = document.getElementById('navMenu');
|
||||
|
||||
menuToggle?.addEventListener('click', () => {
|
||||
const isOpen = navMenu?.classList.toggle('open');
|
||||
menuToggle.classList.toggle('open');
|
||||
menuToggle.setAttribute('aria-expanded', String(isOpen));
|
||||
document.body.style.overflow = isOpen ? 'hidden' : '';
|
||||
});
|
||||
|
||||
// Fermer le menu quand on clique sur un lien
|
||||
navMenu?.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
navMenu.classList.remove('open');
|
||||
menuToggle?.classList.remove('open');
|
||||
menuToggle?.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
329
src/components/photo/Lightbox.astro
Normal file
329
src/components/photo/Lightbox.astro
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
images: { src: string; alt: string; title?: string }[];
|
||||
albumTitle?: string;
|
||||
showCategory?: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const { images, albumTitle = '', showCategory = false, category = '' } = Astro.props;
|
||||
|
||||
const imagesForJS = JSON.stringify(images);
|
||||
|
||||
// Construire les labels depuis la collection
|
||||
const photoCategories = await getCollection('photoCategories');
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'blog': 'Fil Photo',
|
||||
...Object.fromEntries(photoCategories.map(cat => [cat.id, cat.data.title]))
|
||||
};
|
||||
---
|
||||
|
||||
<div id="lightbox" class="lightbox hidden">
|
||||
<div class="lightbox-controls">
|
||||
<button class="lightbox-fullscreen" aria-label="Plein écran">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="lightbox-close" aria-label="Fermer">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="lightbox-prev" aria-label="Image précédente">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 18L9 12L15 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="lightbox-next" aria-label="Image suivante">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18L15 12L9 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="lightbox-image-container">
|
||||
<img id="lightbox-image" class="lightbox-image" src="" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="lightbox-bottom-bar">
|
||||
<div class="lightbox-info">
|
||||
<span id="lightbox-title" class="info-title"></span>
|
||||
{showCategory && (
|
||||
<>
|
||||
<span class="info-separator">•</span>
|
||||
<span id="lightbox-category" class="info-category"></span>
|
||||
</>
|
||||
)}
|
||||
<span class="info-separator">•</span>
|
||||
<span id="lightbox-counter" class="info-counter"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ imagesForJS, albumTitle, showCategory, category, categoryLabels }}>
|
||||
window.lightboxData = {
|
||||
images: JSON.parse(imagesForJS),
|
||||
albumTitle,
|
||||
showCategory,
|
||||
category,
|
||||
categoryLabels
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
class Lightbox {
|
||||
constructor() {
|
||||
this.images = [];
|
||||
this.currentIndex = 0;
|
||||
this.lightbox = document.getElementById('lightbox');
|
||||
this.lightboxImage = document.getElementById('lightbox-image');
|
||||
this.lightboxTitle = document.getElementById('lightbox-title');
|
||||
this.lightboxCategory = document.getElementById('lightbox-category');
|
||||
this.lightboxCounter = document.getElementById('lightbox-counter');
|
||||
this.albumTitle = '';
|
||||
this.showCategory = false;
|
||||
this.category = '';
|
||||
this.categoryLabels = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (window.lightboxData) {
|
||||
this.images = window.lightboxData.images;
|
||||
this.albumTitle = window.lightboxData.albumTitle;
|
||||
this.showCategory = window.lightboxData.showCategory;
|
||||
this.category = window.lightboxData.category;
|
||||
this.categoryLabels = window.lightboxData.categoryLabels;
|
||||
}
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Clic sur les éléments de galerie (thumbnails ou gallery-items)
|
||||
document.querySelectorAll('.thumbnail-link, .gallery-item').forEach((item, index) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.openLightbox(index);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('.lightbox-close')?.addEventListener('click', () => this.closeLightbox());
|
||||
document.querySelector('.lightbox-fullscreen')?.addEventListener('click', () => this.toggleFullscreen());
|
||||
document.querySelector('.lightbox-prev')?.addEventListener('click', () => this.prevImage());
|
||||
document.querySelector('.lightbox-next')?.addEventListener('click', () => this.nextImage());
|
||||
|
||||
// Fermer en cliquant sur le fond
|
||||
this.lightbox?.addEventListener('click', (e) => {
|
||||
if (e.target === this.lightbox || (e.target as HTMLElement).classList.contains('lightbox-image-container')) {
|
||||
this.closeLightbox();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.lightbox?.classList.contains('hidden')) {
|
||||
switch(e.key) {
|
||||
case 'Escape': this.closeLightbox(); break;
|
||||
case 'ArrowLeft': this.prevImage(); break;
|
||||
case 'ArrowRight': this.nextImage(); break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openLightbox(index: number) {
|
||||
this.currentIndex = index;
|
||||
this.updateLightboxContent();
|
||||
this.lightbox?.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
closeLightbox() {
|
||||
this.lightbox?.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
updateLightboxContent() {
|
||||
const image = this.images[this.currentIndex];
|
||||
if (image && this.lightboxImage) {
|
||||
this.lightboxImage.src = image.src;
|
||||
this.lightboxImage.alt = image.alt;
|
||||
|
||||
// Titre: soit le titre de l'image, soit le titre de l'album
|
||||
if (this.lightboxTitle) {
|
||||
this.lightboxTitle.textContent = image.title || this.albumTitle;
|
||||
}
|
||||
|
||||
// Catégorie (optionnel)
|
||||
if (this.showCategory && this.lightboxCategory) {
|
||||
const cat = image.category || this.category;
|
||||
this.lightboxCategory.textContent = this.categoryLabels[cat] || cat;
|
||||
}
|
||||
|
||||
// Compteur
|
||||
if (this.lightboxCounter) {
|
||||
this.lightboxCounter.textContent = `${this.currentIndex + 1} / ${this.images.length}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextImage() {
|
||||
this.currentIndex = (this.currentIndex + 1) % this.images.length;
|
||||
this.updateLightboxContent();
|
||||
}
|
||||
|
||||
prevImage() {
|
||||
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
||||
this.updateLightboxContent();
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
const fullscreenBtn = document.querySelector('.lightbox-fullscreen svg');
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
this.lightbox?.requestFullscreen?.();
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.innerHTML = '<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
}
|
||||
} else {
|
||||
document.exitFullscreen?.();
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.innerHTML = '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => new Lightbox());
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lightbox.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lightbox-image-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100% - 50px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lightbox-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.lightbox-fullscreen,
|
||||
.lightbox-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.lightbox-fullscreen:hover,
|
||||
.lightbox-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox-prev,
|
||||
.lightbox-next {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 30px 20px;
|
||||
z-index: 10000;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.lightbox-prev:hover,
|
||||
.lightbox-next:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.lightbox-prev {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.lightbox-next {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.lightbox-prev svg,
|
||||
.lightbox-next svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.lightbox-bottom-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lightbox-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
78
src/components/photo/MasonryGallery.astro
Normal file
78
src/components/photo/MasonryGallery.astro
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
interface Props {
|
||||
images: {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
}[];
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
const { images, columns = 5 } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="masonry-gallery" style={`--columns: ${columns}`}>
|
||||
{images.map((image, index) => (
|
||||
<div class="gallery-item">
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
widths={[300, 500, 800]}
|
||||
formats={['webp', 'avif']}
|
||||
data-index={index}
|
||||
loading={index < 12 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.masonry-gallery {
|
||||
columns: var(--columns, 5);
|
||||
column-gap: 20px;
|
||||
padding: 40px 20px 100px;
|
||||
max-width: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.masonry-gallery {
|
||||
columns: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.masonry-gallery {
|
||||
columns: 2;
|
||||
padding: 40px 10px 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.masonry-gallery {
|
||||
columns: 1;
|
||||
padding: 40px 15px 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
src/components/photo/PhotoGallery.astro
Normal file
255
src/components/photo/PhotoGallery.astro
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
---
|
||||
import CategoryNav from './CategoryNav.astro';
|
||||
import Slideshow from './Slideshow.astro';
|
||||
import SlideControls from './SlideControls.astro';
|
||||
import favorites from '../../data/favorites.json';
|
||||
|
||||
// Auto-détection des images
|
||||
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/categories/**/*.{jpg,jpeg,png,webp}');
|
||||
|
||||
// Charger les images favorites
|
||||
const favoriteImages = await Promise.all(
|
||||
favorites.map(async (relativePath: string) => {
|
||||
const fullPath = `/src/assets/images/photos/categories/${relativePath}`;
|
||||
const loader = allImages[fullPath];
|
||||
if (!loader) {
|
||||
console.warn(`Image favorite non trouvée: ${fullPath}`);
|
||||
return null;
|
||||
}
|
||||
const img = await loader();
|
||||
const filename = relativePath.split('/').pop() || '';
|
||||
const title = filename
|
||||
.replace(/\.[^/.]+$/, '')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/_/g, ' ');
|
||||
return {
|
||||
src: img.default, // ImageMetadata pour <Image>
|
||||
alt: title,
|
||||
title: title,
|
||||
category: relativePath.split('/')[0]
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrer les nulls (images non trouvées)
|
||||
const images = favoriteImages.filter(img => img !== null);
|
||||
|
||||
// Préparation des données pour JavaScript (avec URL au lieu de ImageMetadata)
|
||||
const imagesForJS = JSON.stringify(images.map(img => ({
|
||||
src: img.src.src, // Extraire l'URL de ImageMetadata
|
||||
alt: img.alt,
|
||||
title: img.title,
|
||||
category: img.category
|
||||
})));
|
||||
---
|
||||
|
||||
<div id="photo-gallery" class="gallery-container">
|
||||
<CategoryNav currentCategory="" />
|
||||
<Slideshow images={images} />
|
||||
<SlideControls />
|
||||
</div>
|
||||
|
||||
<script define:vars={{ imagesForJS }}>
|
||||
window.galleryData = {
|
||||
images: JSON.parse(imagesForJS),
|
||||
currentCategory: 'favorites'
|
||||
};
|
||||
|
||||
// PhotoGallerySlideshow controller
|
||||
class PhotoGallerySlideshow {
|
||||
constructor() {
|
||||
this.images = window.galleryData?.images || [];
|
||||
this.currentIndex = 0;
|
||||
this.isPlaying = true;
|
||||
this.autoplayInterval = null;
|
||||
this.transitionDuration = 800;
|
||||
this.autoplaySpeed = 5000;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.images.length === 0) {
|
||||
console.warn('Aucune image trouvée dans galleryData');
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.startAutoplay();
|
||||
this.preloadNextImages();
|
||||
this.updatePlayPauseButton();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('prev-btn')?.addEventListener('click', () => this.prevSlide());
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => this.nextSlide());
|
||||
document.getElementById('play-pause-btn')?.addEventListener('click', () => this.toggleAutoplay());
|
||||
|
||||
document.querySelectorAll('.indicator').forEach((btn, index) => {
|
||||
btn.addEventListener('click', () => this.goToSlide(index));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.prevSlide();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.nextSlide();
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this.toggleAutoplay();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (window.history.length > 1) {
|
||||
history.back();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.setupTouchEvents();
|
||||
|
||||
if (!this.isMobile()) {
|
||||
const slideshow = document.getElementById('slideshow-container');
|
||||
slideshow?.addEventListener('mouseenter', () => this.pauseAutoplay());
|
||||
slideshow?.addEventListener('mouseleave', () => this.resumeAutoplay());
|
||||
}
|
||||
}
|
||||
|
||||
setupTouchEvents() {
|
||||
const slideshow = document.getElementById('slideshow-container');
|
||||
if (!slideshow) return;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
slideshow.addEventListener('touchstart', (e) => {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
slideshow.addEventListener('touchend', (e) => {
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const deltaX = startX - endX;
|
||||
const deltaY = startY - endY;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
|
||||
if (deltaX > 0) {
|
||||
this.nextSlide();
|
||||
} else {
|
||||
this.prevSlide();
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
goToSlide(index) {
|
||||
if (index === this.currentIndex || index < 0 || index >= this.images.length) return;
|
||||
|
||||
const currentSlide = document.querySelector('.slide.active');
|
||||
const nextSlide = document.querySelector(`[data-index="${index}"]`);
|
||||
const currentIndicator = document.querySelector('.indicator.active');
|
||||
const nextIndicator = document.querySelector(`[data-slide="${index}"]`);
|
||||
|
||||
if (currentSlide && nextSlide) {
|
||||
currentSlide.classList.remove('active');
|
||||
nextSlide.classList.add('active');
|
||||
currentIndicator?.classList.remove('active');
|
||||
nextIndicator?.classList.add('active');
|
||||
this.currentIndex = index;
|
||||
this.preloadNextImages();
|
||||
}
|
||||
}
|
||||
|
||||
nextSlide() {
|
||||
const nextIndex = (this.currentIndex + 1) % this.images.length;
|
||||
this.goToSlide(nextIndex);
|
||||
}
|
||||
|
||||
prevSlide() {
|
||||
const prevIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
||||
this.goToSlide(prevIndex);
|
||||
}
|
||||
|
||||
toggleAutoplay() {
|
||||
if (this.isPlaying) {
|
||||
this.pauseAutoplay();
|
||||
} else {
|
||||
this.startAutoplay();
|
||||
}
|
||||
this.updatePlayPauseButton();
|
||||
}
|
||||
|
||||
startAutoplay() {
|
||||
if (this.autoplayInterval) return;
|
||||
this.isPlaying = true;
|
||||
this.autoplayInterval = setInterval(() => this.nextSlide(), this.autoplaySpeed);
|
||||
}
|
||||
|
||||
pauseAutoplay() {
|
||||
if (this.autoplayInterval) {
|
||||
clearInterval(this.autoplayInterval);
|
||||
this.autoplayInterval = null;
|
||||
}
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
resumeAutoplay() {
|
||||
if (!this.isPlaying) return;
|
||||
this.startAutoplay();
|
||||
}
|
||||
|
||||
updatePlayPauseButton() {
|
||||
const playIcon = document.querySelector('.play-icon');
|
||||
const pauseIcon = document.querySelector('.pause-icon');
|
||||
|
||||
if (this.isPlaying) {
|
||||
playIcon?.classList.add('hidden');
|
||||
pauseIcon?.classList.remove('hidden');
|
||||
} else {
|
||||
playIcon?.classList.remove('hidden');
|
||||
pauseIcon?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
preloadNextImages() {
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const nextIndex = (this.currentIndex + i) % this.images.length;
|
||||
const nextImage = this.images[nextIndex];
|
||||
|
||||
if (nextImage && !document.querySelector(`img[src="${nextImage.src}"]`)) {
|
||||
const img = new Image();
|
||||
img.src = nextImage.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new PhotoGallerySlideshow();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.gallery-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
79
src/components/photo/ScrollIndicator.astro
Normal file
79
src/components/photo/ScrollIndicator.astro
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
const { targetSelector = '.info-section' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="scroll-indicator" id="scrollArrow" data-target={targetSelector}>
|
||||
<div class="scroll-indicator-bg"></div>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6,9 12,15 18,9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: white;
|
||||
animation: bounce 2s infinite;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.scroll-indicator-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scroll-indicator:hover .scroll-indicator-bg {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.scroll-indicator svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-50%) translateY(-5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const scrollArrow = document.getElementById('scrollArrow');
|
||||
if (scrollArrow) {
|
||||
scrollArrow.addEventListener('click', () => {
|
||||
const targetSelector = scrollArrow.getAttribute('data-target');
|
||||
const targetElement = document.querySelector(targetSelector);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
92
src/components/photo/SlideControls.astro
Normal file
92
src/components/photo/SlideControls.astro
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
---
|
||||
|
||||
<div class="slide-controls">
|
||||
<button
|
||||
id="prev-btn"
|
||||
class="control-btn prev-btn"
|
||||
aria-label="Image précédente"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="next-btn"
|
||||
class="control-btn next-btn"
|
||||
aria-label="Image suivante"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slide-controls {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.control-btn:focus {
|
||||
outline: 2px solid #007acc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.prev-btn {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 2rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 2rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.prev-btn,
|
||||
.next-btn {
|
||||
top: auto;
|
||||
bottom: 2rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.prev-btn {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
src/components/photo/Slideshow.astro
Normal file
141
src/components/photo/Slideshow.astro
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
const { images = [] } = Astro.props;
|
||||
---
|
||||
|
||||
<div id="slideshow-container" class="slideshow-wrapper">
|
||||
<div class="slides-track">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
class={`slide ${index === 0 ? 'active' : ''}`}
|
||||
data-index={index}
|
||||
>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
widths={[800, 1200, 1920]}
|
||||
formats={['webp', 'avif']}
|
||||
class="slide-image"
|
||||
loading={index < 3 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Indicateurs de progression -->
|
||||
<div class="slide-indicators">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
class={`indicator ${index === 0 ? 'active' : ''}`}
|
||||
data-slide={index}
|
||||
aria-label={`Aller à l'image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slideshow-wrapper {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slides-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
will-change: opacity;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Force pour s'assurer qu'aucune autre slide ne reste visible */
|
||||
.slide:not(.active) {
|
||||
opacity: 0 !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.slide-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.slide-indicators {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator.active {
|
||||
background: white;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.indicator:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.indicator:focus {
|
||||
outline: 2px solid #007acc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slide-indicators {
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimisations performance */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slide {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.slide-image {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
const postCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
dateFormatted: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
post: postCollection,
|
||||
};
|
||||
85
src/content/config.ts
Normal file
85
src/content/config.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { defineCollection, z, type ImageFunction } from "astro:content";
|
||||
|
||||
const blogCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.date(),
|
||||
dateFormatted: z.string(),
|
||||
category: z.enum(['pro', 'comedy', 'photo']),
|
||||
tags: z.array(z.string()).optional(),
|
||||
image: z.string().optional(),
|
||||
imageAlt: z.string().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
|
||||
}),
|
||||
});
|
||||
|
||||
const projectsCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.date(),
|
||||
dateFormatted: z.string(),
|
||||
category: z.enum(['dev', 'comedy', 'photo']),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
url: z.string().url().optional(),
|
||||
github: z.string().url().optional(),
|
||||
image: z.string().optional(),
|
||||
imageAlt: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
draft: z.boolean().default(false),
|
||||
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
|
||||
}),
|
||||
});
|
||||
|
||||
const talksCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.date(),
|
||||
dateFormatted: z.string(),
|
||||
event: z.string(),
|
||||
location: z.string(),
|
||||
slides: z.string().url().optional(),
|
||||
video: z.string().url().optional(),
|
||||
image: z.string().optional(),
|
||||
imageAlt: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
draft: z.boolean().default(false),
|
||||
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
|
||||
}),
|
||||
});
|
||||
|
||||
const photoBlogPostsCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: ({ image }) => z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.date(),
|
||||
coverImage: image(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
featured: z.boolean().default(false),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
const photoCategoriesCollection = defineCollection({
|
||||
type: "data",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
order: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
blog: blogCollection,
|
||||
projects: projectsCollection,
|
||||
talks: talksCollection,
|
||||
photoBlogPosts: photoBlogPostsCollection,
|
||||
photoCategories: photoCategoriesCollection,
|
||||
};
|
||||
15
src/content/photoBlogPosts/enigma.md
Normal file
15
src/content/photoBlogPosts/enigma.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Enigma"
|
||||
description: "Série artistique en noir et blanc"
|
||||
date: 2015-04-25
|
||||
coverImage: "../../assets/images/photos/blog/enigma/01-Enigma-v1.jpg"
|
||||
tags: []
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Enigma
|
||||
|
||||
Série artistique en noir et blanc
|
||||
|
||||
**40 photos**
|
||||
15
src/content/photoBlogPosts/eroll.md
Normal file
15
src/content/photoBlogPosts/eroll.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Shooting Eroll"
|
||||
description: "Séance photo portrait et sport avec Eroll"
|
||||
date: 2011-10-02
|
||||
coverImage: "../../assets/images/photos/blog/eroll/01-Eroll-Shooting-1.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Shooting Eroll
|
||||
|
||||
Séance photo portrait et sport avec Eroll
|
||||
|
||||
**17 photos**
|
||||
15
src/content/photoBlogPosts/field-of-stones.md
Normal file
15
src/content/photoBlogPosts/field-of-stones.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Field of Stones"
|
||||
description: "Shooting avec Marco Wolter"
|
||||
date: 2015-04-02
|
||||
coverImage: "../../assets/images/photos/blog/field-of-stones/01-Marco-Wolter-Field-of-Stones-2.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Field of Stones
|
||||
|
||||
Shooting avec Marco Wolter
|
||||
|
||||
**11 photos**
|
||||
15
src/content/photoBlogPosts/helsinki.md
Normal file
15
src/content/photoBlogPosts/helsinki.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Helsinki"
|
||||
description: "Découverte de la capitale finlandaise"
|
||||
date: 2013-05-15
|
||||
coverImage: "../../assets/images/photos/blog/helsinki/01-Library-of-University-of-Helsinki.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Helsinki
|
||||
|
||||
Découverte de la capitale finlandaise
|
||||
|
||||
**14 photos**
|
||||
15
src/content/photoBlogPosts/ifrane-hike.md
Normal file
15
src/content/photoBlogPosts/ifrane-hike.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Randonnée à Ifrane"
|
||||
description: "Randonnée hivernale dans les montagnes du Moyen Atlas"
|
||||
date: 2013-01-13
|
||||
coverImage: "../../assets/images/photos/blog/ifrane-hike/01-.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Randonnée à Ifrane
|
||||
|
||||
Randonnée hivernale dans les montagnes du Moyen Atlas
|
||||
|
||||
**9 photos**
|
||||
15
src/content/photoBlogPosts/inox-park-2011.md
Normal file
15
src/content/photoBlogPosts/inox-park-2011.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Inox Park Paris 2011"
|
||||
description: "Festival de musique électronique à Chatou"
|
||||
date: 2011-09-10
|
||||
coverImage: "../../assets/images/photos/blog/inox-park-2011/01-Inox-Park-Paris-Chatou-2011.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Inox Park Paris 2011
|
||||
|
||||
Festival de musique électronique à Chatou
|
||||
|
||||
**18 photos**
|
||||
15
src/content/photoBlogPosts/london-calling.md
Normal file
15
src/content/photoBlogPosts/london-calling.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "London Calling"
|
||||
description: "Week-end photographique à Londres"
|
||||
date: 2014-07-15
|
||||
coverImage: "../../assets/images/photos/blog/london-calling/01-The-sky-inside.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# London Calling
|
||||
|
||||
Week-end photographique à Londres
|
||||
|
||||
**7 photos**
|
||||
15
src/content/photoBlogPosts/no-wind-las-cuevas.md
Normal file
15
src/content/photoBlogPosts/no-wind-las-cuevas.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Pas de vent à Las Cuevas"
|
||||
description: "Journée kitesurf à Tarifa"
|
||||
date: 2015-01-10
|
||||
coverImage: "../../assets/images/photos/blog/no-wind-las-cuevas/01-No-wind-at-Las-Cuevas.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Pas de vent à Las Cuevas
|
||||
|
||||
Journée kitesurf à Tarifa
|
||||
|
||||
**8 photos**
|
||||
15
src/content/photoBlogPosts/schoolbag-operation-2012.md
Normal file
15
src/content/photoBlogPosts/schoolbag-operation-2012.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Opération Cartable 2012"
|
||||
description: "Distribution de cartables par la JCI Tanger"
|
||||
date: 2012-09-30
|
||||
coverImage: "../../assets/images/photos/blog/schoolbag-operation-2012/01-.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Opération Cartable 2012
|
||||
|
||||
Distribution de cartables par la JCI Tanger
|
||||
|
||||
**51 photos**
|
||||
15
src/content/photoBlogPosts/sequanian-sunday.md
Normal file
15
src/content/photoBlogPosts/sequanian-sunday.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Dimanche Séquanais"
|
||||
description: "Balade dominicale le long de la Seine"
|
||||
date: 2014-05-18
|
||||
coverImage: "../../assets/images/photos/blog/sequanian-sunday/01-Court-of-Audit-Paris.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Dimanche Séquanais
|
||||
|
||||
Balade dominicale le long de la Seine
|
||||
|
||||
**10 photos**
|
||||
15
src/content/photoBlogPosts/tangier-walk.md
Normal file
15
src/content/photoBlogPosts/tangier-walk.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Balade à Tanger"
|
||||
description: "Promenade photographique dans les rues de Tanger"
|
||||
date: 2012-05-26
|
||||
coverImage: "../../assets/images/photos/blog/tangier-walk/01-Observer-le-changement.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Balade à Tanger
|
||||
|
||||
Promenade photographique dans les rues de Tanger
|
||||
|
||||
**9 photos**
|
||||
15
src/content/photoBlogPosts/waiting-for-the-bride.md
Normal file
15
src/content/photoBlogPosts/waiting-for-the-bride.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "En attendant la mariée"
|
||||
description: "Préparatifs d'un mariage"
|
||||
date: 2014-10-25
|
||||
coverImage: "../../assets/images/photos/blog/waiting-for-the-bride/01-.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# En attendant la mariée
|
||||
|
||||
Préparatifs d'un mariage
|
||||
|
||||
**3 photos**
|
||||
15
src/content/photoBlogPosts/wandering-tangier-medina.md
Normal file
15
src/content/photoBlogPosts/wandering-tangier-medina.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: "Dans la médina de Tanger"
|
||||
description: "Flânerie dans les ruelles de la médina"
|
||||
date: 2014-08-10
|
||||
coverImage: "../../assets/images/photos/blog/wandering-tangier-medina/01-The-watchmaker.jpg"
|
||||
tags: []
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Dans la médina de Tanger
|
||||
|
||||
Flânerie dans les ruelles de la médina
|
||||
|
||||
**10 photos**
|
||||
11
src/content/photoBlogPosts/wedding-aurore-thomas.md
Normal file
11
src/content/photoBlogPosts/wedding-aurore-thomas.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
title: "Mariage Aurore & Thomas"
|
||||
description: "Reportage de mariage"
|
||||
date: 2015-09-26
|
||||
coverImage: "../../assets/images/photos/blog/wedding-aurore-thomas/01-Mariage-Aurore-Thomas.jpg"
|
||||
tags: []
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
**13 photos**
|
||||
5
src/content/photoCategories/cultures.json
Normal file
5
src/content/photoCategories/cultures.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Cultures et Traditions",
|
||||
"subtitle": "Richesse des traditions humaines",
|
||||
"order": 4
|
||||
}
|
||||
5
src/content/photoCategories/engines.json
Normal file
5
src/content/photoCategories/engines.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Moteurs",
|
||||
"subtitle": "Mécanique et puissance en mouvement",
|
||||
"order": 7
|
||||
}
|
||||
5
src/content/photoCategories/everyday.json
Normal file
5
src/content/photoCategories/everyday.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Quotidien",
|
||||
"subtitle": "Instants du quotidien",
|
||||
"order": 8
|
||||
}
|
||||
5
src/content/photoCategories/music.json
Normal file
5
src/content/photoCategories/music.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Musique et Célébrations",
|
||||
"subtitle": "Notes, chansons et autres vibrations qui célèbrent les petits et grands moments de nos vies",
|
||||
"order": 5
|
||||
}
|
||||
5
src/content/photoCategories/nature.json
Normal file
5
src/content/photoCategories/nature.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Nature",
|
||||
"subtitle": "La magie du monde naturel",
|
||||
"order": 3
|
||||
}
|
||||
5
src/content/photoCategories/places.json
Normal file
5
src/content/photoCategories/places.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Paysages",
|
||||
"subtitle": "Beauté des paysages et des lieux",
|
||||
"order": 2
|
||||
}
|
||||
5
src/content/photoCategories/portraits.json
Normal file
5
src/content/photoCategories/portraits.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Portraits",
|
||||
"subtitle": "Expressions et émotions capturées",
|
||||
"order": 1
|
||||
}
|
||||
5
src/content/photoCategories/sports.json
Normal file
5
src/content/photoCategories/sports.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"title": "Sports",
|
||||
"subtitle": "Mouvement, effort et dépassement",
|
||||
"order": 6
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Browser locally uses AI to remove image backgrounds
|
||||
description: Browser locally uses AI to remove image backgrounds
|
||||
dateFormatted: Jul 14th, 2024
|
||||
---
|
||||
|
||||
Yo, so I've been digging into this whole AI thing for front-end development lately, and stumbled upon this cool Transformers.js example. Turned it into a sweet little tool, check it out!
|
||||
|
||||
Basically, it uses Transformers.js in a WebWorker to tap into WebGPU and run this RMBG-1.4 model. Long story short, you can now use AI to nuke image backgrounds right in your browser. And get this, it only takes half a second to process a 4K image on my M1 PRO!
|
||||
|
||||
Here's the link to the tool: [https://html.zone/background-remover](https://html.zone/background-remover)
|
||||
|
||||
[](https://html.zone/background-remover)
|
||||
|
||||
* * *
|
||||
|
||||
Wanna build it yourself? Head over to [https://github.com/xenova/transformers.js/tree/main/examples/remove-background-client](https://github.com/xenova/transformers.js/tree/main/examples/remove-background-client) for the source code. Oh, and heads up, you gotta be on Transformers.js V3 to mess with WebGPU.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Aria - a minimalist Astro homepage template
|
||||
description: Aria is a template for Astro
|
||||
dateFormatted: Jun 6th, 2024
|
||||
---
|
||||
|
||||
[](https://github.com/ccbikai/astro-aria)
|
||||
|
||||
Aria is a template I found on [https://aria.devdojo.io/](https://aria.devdojo.io/). It's clean and beautiful, so I decided to use it for my own homepage and ported it to Astro.
|
||||
|
||||
It's already open source, so feel free to use it if you're interested.
|
||||
|
||||
<https://github.com/ccbikai/astro-aria>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: BroadcastChannel - Turn your Telegram Channel into a MicroBlog
|
||||
description: Turn your Telegram Channel into a MicroBlog
|
||||
dateFormatted: Aug 11th, 2024
|
||||
---
|
||||
|
||||
I have been sharing some interesting tools on [X](https://x.com/ccbikai) and also synchronizing them to my Telegram Channel. I saw that [Austin mentioned he is preparing to create a website](https://x.com/austinit/status/1817832660758081651) to compile all the shared content. This reminded me of a template I recently came across called [Sepia](https://github.com/Planetable/SiteTemplateSepia), and I thought about converting the Telegram Channel into a microblog.
|
||||
|
||||
The difficulty wasn't high; I completed the main functionality over a weekend. During the process, I achieved a browser-side implementation with zero JavaScript and would like to share some interesting technical points:
|
||||
|
||||
1. The anti-spoiler mode and the hidden display of the mobile search box were implemented using the CSS ":checked pseudo-class" and the "+ adjacent sibling combinator." [Reference](https://www.tpisoftware.com/tpu/articleDetails/2744)
|
||||
|
||||
2. The transition animations utilized CSS View Transitions. [Reference](https://liruifengv.com/posts/zero-js-view-transitions/)
|
||||
|
||||
3. The image lightbox used the HTML popover attribute. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/popover)
|
||||
|
||||
4. The display and hiding of the "back to top" feature were implemented using CSS animation-timeline, exclusive to Chrome version 115 and above. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation-timeline/view)
|
||||
|
||||
5. The multi-image masonry layout was achieved using grid layout. [Reference](https://www.smashingmagazine.com/native-css-masonry-layout-css-grid/)
|
||||
|
||||
6. The visit statistics were tracked using a 1px transparent image as the logo background, an ancient technique that is now rarely supported by visit statistics software.
|
||||
|
||||
7. JavaScript execution on the browser side was prohibited using the Content-Security-Policy's script-src 'none'. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Security-Policy/script-src)
|
||||
|
||||
After completing the project, I open-sourced it, and I was pleasantly surprised by the number of people who liked it; I received over 800 stars in just a week.
|
||||
|
||||
If you're interested, you can check it out on GitHub.
|
||||
|
||||
<https://github.com/ccbikai/BroadcastChannel>
|
||||
|
||||
[](https://github.com/ccbikai/BroadcastChannel)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Solving the issue of Cloudflare Web Analytics being blocked by AdBlock
|
||||
description: Solving the issue of Cloudflare Web Analytics being blocked by AdBlock
|
||||
dateFormatted: Jan 8th, 2024
|
||||
---
|
||||
|
||||
Earlier, we solved the issues of [Vercel Analytics](https://dev.to/ccbikai/jie-jue-vercel-analytics-bei-adblock-ping-bi-wen-ti-1o21-temp-slug-5601874) and [Umami](https://dev.to/ccbikai/jie-jue-umami-bei-adblock-ping-bi-wen-ti-3kc2-temp-slug-2355567) being blocked by AdBlock, and now we are also going to solve the problem for [Email.ML](https://email.ml/) which uses [Cloudflare Web Analytics](https://www.cloudflare.com/zh-cn/web-analytics/).
|
||||
|
||||
Cloudflare Web Analytics is blocked by the `||cloudflareinsights.com^` rule. Its script address is `https://static.cloudflareinsights.com/beacon.min.js`, and the data reporting address is `https://cloudflareinsights.com/cdn-cgi/rum`.
|
||||
|
||||

|
||||
|
||||
So, just like Umami, we will proxy the script address and forward the data to the data reporting address.
|
||||
|
||||
## Solution
|
||||
|
||||
Create a Worker in Cloudflare Workers and paste the following JavaScript code. Configure the domain and test if the script address can be accessed properly. Mine is [https://cwa.miantiao.me/mt-demo.js](https://cwa.miantiao.me/mt-demo.js). The `mt-demo` can be replaced with any disguise address, the script above is already adapted.
|
||||
|
||||
```js
|
||||
const CWA_API = 'https://cloudflareinsights.com/cdn-cgi/rum'
|
||||
const CWA_SCRIPT = 'https://static.cloudflareinsights.com/beacon.min.js'
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
let { pathname, search } = new URL(request.url)
|
||||
if (pathname.endsWith('.js')) {
|
||||
let response = await caches.default.match(request)
|
||||
if (!response) {
|
||||
response = await fetch(CWA_SCRIPT, request)
|
||||
ctx.waitUntil(caches.default.put(request, response.clone()))
|
||||
}
|
||||
return response
|
||||
}
|
||||
const req = new Request(request)
|
||||
req.headers.delete("cookie")
|
||||
const response = await fetch(`${CWA_API}${search}`, req)
|
||||
const headers = Object.fromEntries(response.headers.entries())
|
||||
if (!response.headers.has('Access-Control-Allow-Origin')) {
|
||||
headers['Access-Control-Allow-Origin'] = request.headers.get('Origin') || '*'
|
||||
}
|
||||
if (!response.headers.has('Access-Control-Allow-Headers')) {
|
||||
headers['Access-Control-Allow-Headers'] = 'content-type'
|
||||
}
|
||||
if (!response.headers.has('Access-Control-Allow-Credentials')) {
|
||||
headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
Then inject the script into your website project, referring to my code:
|
||||
|
||||
```html
|
||||
<script async src='https://cwa.miantiao.me/mt-demo.js' data-cf-beacon='{"send":{"to": "https://cwa.miantiao.me/mt-demo"},"token": "5403f4dc926c4e61a757d630b1ec21ad"}'></script>
|
||||
|
||||
```
|
||||
|
||||
`src` is the script address, replace `mt-demo` with any disguise address. `data-cf-beacon` contains the send to data reporting address, replace `mt-demo` with any disguise address, the script is already adapted. Remember to change the `token` to your site's token.
|
||||
|
||||
You can verify it on [Email.ML](https://email.ml/) or [HTML.ZONE](https://html.zone/).
|
||||
|
||||
**Note that using this solution requires disabling automatic configuration, otherwise the data will not be counted.**
|
||||
|
||||

|
||||
|
|
@ -1,104 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Processing Images with Cloudflare Worker
|
||||
description: Processing Images with Cloudflare Worker
|
||||
dateFormatted: Nov 18th, 2023
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Previously, I set up a 10GB storage, unlimited bandwidth cloud storage using [Backblaze B2](https://www.backblaze.com/cloud-storage) and Cloudflare, which I use for daily file sharing and as an image hosting service for my blog. It works well with uPic. However, when using it as an image hosting service for my blog, I found that it doesn't support image resizing/cropping. I often use Alibaba Cloud OSS for image processing at work, and I couldn't stand the limitation, so I decided to create my own service.
|
||||
|
||||
> The free version of Workers only has a CPU limit of 10ms, and it frequently exceeds the resource usage limit, resulting in a high rate of image cracking. Now it has been adapted to Vercel Edge, which can be used with a CDN. See [https://chi.miantiao.me/post/cloudflare-worker-image/](https://chi.miantiao.me/post/cloudflare-worker-image/)
|
||||
|
||||
## Process
|
||||
|
||||
After some research, I considered two options:
|
||||
|
||||
1. Use Cloudflare to proxy [Vercel Image](https://vercel.com/docs/image-optimization). With this option, the traffic goes through Cloudflare -> Vercel -> Cloudflare -> Backblaze, which is not ideal in terms of stability and speed. Additionally, it only allows 1000 image processing requests per month, which is quite limited.
|
||||
|
||||
2. Use the public service [wsrv.nl](https://images.weserv.nl/). With this option, the traffic goes through Cloudflare -> wsrv.nl -> Cloudflare -> Backblaze, and the domain is not under my control. If I want to control the domain, I would have to go through Cloudflare Worker again, which adds complexity.
|
||||
|
||||
Since neither option was ideal, I kept looking for alternatives. Last week, when I was working on an Email Worker, I discovered that Cloudflare Worker supports [WebAssembly (Wasm)](https://developers.cloudflare.com/workers/runtime-apis/webassembly/), which sparked the idea of using Worker + WebAssembly to process images.
|
||||
|
||||
Initially, I wanted to use [sharp](https://sharp.pixelplumbing.com/), which I had used when working with Node.js. However, the author mentioned that Cloudflare Worker does not support multithreading, so sharp cannot run on Cloudflare Worker in the short term.
|
||||
|
||||
I searched online and found that a popular Rust library for image processing is [Photon](https://silvia-odwyer.github.io/photon/), and there is also a [demo](https://github.com/techwithdeo/cloudflare-workers/tree/main/photon-library) in the community. I tried it out and confirmed that it can run on Cloudflare Worker. However, the demo has two drawbacks:
|
||||
|
||||
1. Photon needs to be manually updated and cannot keep up with the official updates as quickly.
|
||||
2. It can only output images in PNG format, and the file size of JPG images actually becomes larger after resizing.
|
||||
|
||||
## Result
|
||||
|
||||
Based on the keywords "Photon + Worker", I did further research and came up with a new solution inspired by [DenoFlare](https://denoflare.dev/examples/transform-images-wasm) and [jSquash](https://github.com/jamsinclair/jSquash). In the end, I used the official Photon (with patch-package as a dependency), Squash WebAssembly, and Cloudflare Worker to create an image processing service for resizing images. _I originally wanted to support output in AVIF and JPEG XL formats, but due to the 1MB size limit of the free version of Workers, I had to give up this feature_.
|
||||
|
||||
Supported features:
|
||||
|
||||
1. Supports processing of PNG, JPG, BMP, ICO, and TIFF format images.
|
||||
2. Can output images in JPG, PNG, and WEBP formats, with WEBP being the default.
|
||||
3. Supports pipelining, allowing multiple operations to be executed.
|
||||
4. Supports Cloudflare caching.
|
||||
5. Supports whitelisting of image URLs to prevent abuse.
|
||||
6. Degrades gracefully in case of exceptions, returning the original image (exceptions are not cached).
|
||||
|
||||
## Demo
|
||||
|
||||
### Format Conversion
|
||||
|
||||
#### webp
|
||||
|
||||

|
||||
|
||||
#### jpg
|
||||
|
||||

|
||||
|
||||
#### png
|
||||
|
||||

|
||||
|
||||
### Resizing
|
||||
|
||||

|
||||
|
||||
### Rotation
|
||||
|
||||

|
||||
|
||||
### Cropping
|
||||
|
||||

|
||||
|
||||
### Filters
|
||||
|
||||

|
||||
|
||||
### Image Watermark
|
||||
|
||||

|
||||
|
||||
### Text Watermark
|
||||
|
||||

|
||||
|
||||
### Pipeline Operations
|
||||
|
||||
#### Resize + Rotate + Text Watermark
|
||||
|
||||

|
||||
|
||||
#### Resize + Image Watermark
|
||||
|
||||

|
||||
|
||||
In theory, it supports all the operations of Photon. If you are interested, you can check the image URLs and modify the parameters according to the [Photon documentation](https://docs.rs/photon-rs/latest/photon_rs/) to try it out yourself. If you encounter any issues, feel free to leave a comment and provide feedback.
|
||||
|
||||
## Sharing
|
||||
|
||||
I have open-sourced this solution on my GitHub. If you need it, you can follow the documentation to deploy it.
|
||||
|
||||
[](https://github.com/ccbikai/cloudflare-worker-image)
|
||||
|
||||
* * *
|
||||
|
||||
[](https://www.buymeacoffee.com/ccbikai)
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Low-Cost Deployment of Federated Universe Personal Instances
|
||||
description: Low-Cost Deployment of Federated Universe Personal Instances
|
||||
dateFormatted: Nov 27th, 2023
|
||||
---
|
||||
|
||||
I came across the concept of the Fediverse at the beginning of this year and found that it is the social network I have always envisioned: each instance is like an isolated island, connected through the network to communicate with each other.
|
||||
|
||||
> To learn more about the Fediverse, you can read the blog posts from these individuals:
|
||||
>
|
||||
> - [Introduction to the Fediverse](https://zerovip.vercel.app/zh/59563/)
|
||||
> - [Fediverse: The Federated Universe](https://wzyboy.im/post/1486.html)
|
||||
> - [What is the Fediverse and Can It Decentralize the Internet?](https://fermi.ink/posts/2022/11/22/01/)
|
||||
> - [What is Mastodon and How to Use It](https://limboy.me/posts/mastodon/)
|
||||
> - [Fediverse Guide for Twitter Users](https://wzyboy.im/post/1513.html)
|
||||
|
||||
As a self-hosting enthusiast, I wanted to deploy my own instance. I asked about the cost of self-hosting on Mastodon and found that the minimum cost is $15/year for a server and domain name. In order to reduce costs, I didn't purchase a VPS and instead deployed my own instance on my Homelab. It has been running for half a year with a few issues (mainly due to my tinkering) such as internet or power outages at home. Since downtime results in lost messages, I decided to migrate to a server.
|
||||
|
||||
Among the popular software, Mastodon has more features but consumes more resources, so I chose [Pleroma](https://pleroma.social/) which consumes fewer resources but still meets my needs. I deployed it on various free services, achieving a server cost of $0 with only the domain name cost remaining. It has been running stable for a quarter.
|
||||
|
||||

|
||||
|
||||
Therefore, I would like to share this solution:
|
||||
|
||||
- Cloud platforms:
|
||||
1. [Koyeb](https://app.koyeb.com/)
|
||||
2. [Northflank](https://northflank.com/)
|
||||
3. [Zeabur](https://s.mt.ci/WrK7Dc) (Originally free, but now only available through subscription plans (free plan is for testing only))
|
||||
|
||||
- Database:
|
||||
1. [Aiven](https://s.mt.ci/dgQGhM)
|
||||
2. [Neon](https://neon.tech/)
|
||||
|
||||
- Cloud storage:
|
||||
1. [Cloudflare R2](https://www.cloudflare.com/zh-cn/developer-platform/r2/)
|
||||
2. [Backblaze B2](https://www.backblaze.com/)
|
||||
|
||||
- CDN:
|
||||
1. [Cloudflare](https://www.cloudflare.com/)
|
||||
|
||||
Deployment tutorial:
|
||||
|
||||
[](https://github.com/ccbikai/pleroma-on-cloud)
|
||||
|
||||
Remember, free things are often the most expensive. It is important to regularly back up the database and cloud storage.
|
||||
|
||||
**Lastly, feel free to follow me on the Fediverse (Mastodon, Pleroma, etc.) at [@chi@miantiao.me](https://miantiao.me/@chi).**
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: DNS.Surf - check DNS resolution results in different regions
|
||||
description: DNS.Surf - check DNS resolution results in different regions
|
||||
dateFormatted: Nov 8th, 2023
|
||||
---
|
||||
|
||||
|
||||
[**DNS.Surf**](https://dns.surf/) is like a traveler that helps you explore the scenery of DNS resolution results in different regions.
|
||||
|
||||
It provides resolution services from 18 regions and has over 100 optional DNS resolvers, just like choosing how to travel between different cities and countries.
|
||||
|
||||
This website runs entirely on Vercel, like a stable and efficient means of transportation, providing you with fast and reliable service.
|
||||
|
||||
## Privacy
|
||||
|
||||
For privacy concerns, you can use it with confidence, as the website does not collect or store any user information. It's like enjoying the scenery during your travels without worrying about personal information leakage.
|
||||
|
||||
## Website
|
||||
|
||||
[https://dns.surf/](https://dns.surf/)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Email.ML - minimalistic temporary email
|
||||
description: Email.ML - minimalistic temporary email
|
||||
dateFormatted: Jun 6th, 2024
|
||||
---
|
||||
|
||||
[**Email.ML**](https://email.ml/) is a minimalistic temporary email service.
|
||||
|
||||
You can get a temporary email without revealing any personal information, which greatly protects your privacy.
|
||||
|
||||
It supports selecting multiple domain names, making it convenient for you to use in different scenarios.
|
||||
|
||||
100% running on the **Cloudflare** network, providing you with a super-fast experience.
|
||||
|
||||
## Statement
|
||||
|
||||
This service is not available in China Mainland.
|
||||
|
||||
## Privacy
|
||||
|
||||
This site only stores an email name for this session, and the emails are temporarily stored in **Cloudflare** data centers. They will be completely deleted after the email expires.
|
||||
|
||||
## Website
|
||||
|
||||
[https://email.ml/](https://email.ml/)
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Extract GitHub OpenGraph Images for Card Previews
|
||||
description: Extract GitHub OpenGraph Images for Card Previews
|
||||
dateFormatted: Dec 19th, 2023
|
||||
---
|
||||
|
||||
Previously, when sharing GitHub on my blog, I always used [GitHub Repository Card](https://gh-card.dev/) for sharing, but it doesn't have good support for Chinese and doesn't support line breaks.
|
||||
|
||||
[](https://github.com/ccbikai/cloudflare-worker-image)
|
||||
|
||||
Originally, I planned to create my own using [@vercel/og](https://vercel.com/docs/functions/edge-functions/og-image-generation), but I accidentally discovered that GitHub provides comprehensive and beautiful Open Graph images on Twitter. So, I wrote a script to extract and use them for blog previews.
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
In addition to repositories, GitHub's Open Graph also supports previews for Issue, Pull Request, Discussion, and Commit modules.
|
||||
|
||||
## Usage
|
||||
|
||||
**Modify `.com` to `.html.zone` on any GitHub page**.
|
||||
|
||||
For example, [https://github.com/vercel/next.js](https://github.com/vercel/next.js) => [https://github.html.zone/vercel/next.js](https://github.html.zone/vercel/next.js).
|
||||
|
||||
### Previews
|
||||
|
||||
#### Repo
|
||||
|
||||

|
||||
|
||||
#### Issue
|
||||
|
||||

|
||||
|
||||
#### Pull Request
|
||||
|
||||

|
||||
|
||||
#### Discussion
|
||||
|
||||

|
||||
|
||||
#### Commit
|
||||
|
||||

|
||||
|
||||
## Source Code
|
||||
|
||||
The code has been shared on GitHub for those interested to explore.
|
||||
|
||||
[](https://github.com/ccbikai/github-og-image)
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: How to Replace Google Safe Browsing with Cloudflare Zero Trust
|
||||
description: How to Replace Google Safe Browsing with Cloudflare Zero Trust
|
||||
dateFormatted: Jul 14th, 2024
|
||||
---
|
||||
|
||||
So, get this, right? I built the first version of [L(O\*62).ONG](https://loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong/) using server-side redirects, but Google slapped me with a security warning the very next day. Talk about a buzzkill! I had to scramble and switch to local redirects with a warning message before sending folks on their way. Then came the fun part – begging Google for forgiveness.
|
||||
|
||||
Now, the smart money would've been on using Google Safe Browsing for redirects. But here's the catch: Safe Browsing's got a daily limit – 10,000 calls, and that's it. Plus, no custom lists. And since I'm all about keeping things simple and sticking with Cloudflare, Safe Browsing was a no-go.
|
||||
|
||||
Fast forward to a while back, I was chewing the fat with someone online, and bam! It hit me like a bolt of lightning. Why not use a secure DNS server with built-in filters for adult content and all that shady stuff to check if a domain's on the up-and-up? Figured I'd give [Family 1.1.1.1](https://blog.cloudflare.com/zh-cn/introducing-1-1-1-1-for-families-zh-cn/) a shot, and guess what? It actually worked! Problem was, no custom lists there either. Then I remembered messing around with Cloudflare Zero Trust Gateway back in my [HomeLab](https://www.awesome-homelab.com/) days. Turns out, that was the golden ticket – a solution so good, it's almost criminal.
|
||||
|
||||
**Here's the deal: Cloudflare Zero Trust's Gateway comes packing a built-in DNS (DoH) server and lets you set up firewall rules like a boss. You can block stuff based on how risky a domain is, what kind of content it has, and even use your own custom naughty-and-nice lists. And get this – it pulls data from Cloudflare's own stash, over 30 open intelligence sources, fancy machine learning models, and even feedback from the community. Talk about covering all the bases! Want the nitty-gritty? Hit up the [official documentation](https://developers.cloudflare.com/cloudflare-one/policies/gateway/domain-categories/#docs-content).**
|
||||
|
||||
So, I went ahead and blocked all the high-risk categories – adult stuff, gambling sites, government domains, anything NSFW, newly registered domains, you name it. Plus, I've got my own little blacklists and whitelists that I keep nice and tidy.
|
||||
|
||||

|
||||
|
||||
Once I was done tweaking the settings, I got myself a shiny new DoH address:
|
||||
|
||||

|
||||
|
||||
To hook it up to my project, I used this handy-dandy code:
|
||||
|
||||
```
|
||||
async function isSafeUrl(
|
||||
url,
|
||||
DoH = "https://family.cloudflare-dns.com/dns-query"
|
||||
) {
|
||||
let safe = false;
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const res = await fetch(`${DoH}?type=A&name=${hostname}`, {
|
||||
headers: {
|
||||
accept: "application/dns-json",
|
||||
},
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtlByStatus: { "200-299": 86400 },
|
||||
},
|
||||
});
|
||||
const dnsResult = await res.json();
|
||||
if (dnsResult && Array.isArray(dnsResult.Answer)) {
|
||||
const isBlock = dnsResult.Answer.some(
|
||||
answer => answer.data === "0.0.0.0"
|
||||
);
|
||||
safe = !isBlock;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("isSafeUrl fail: ", url, e);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
And here's the kicker: Cloudflare Zero Trust's management panel has this sweet visualization interface that lets you see what's getting blocked and what's not. You can see for yourself – it's got the kibosh on some adult sites and those brand-spanking-new domains.
|
||||
|
||||

|
||||
|
||||
Oh, and if a domain ends up on the wrong side of the tracks, you can always check the log to see what went down.
|
||||
|
||||

|
||||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: L(O*62).ONG - Make your URL longer
|
||||
description: loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong is the longest domain name
|
||||
dateFormatted: Jun 1th, 2024
|
||||
---
|
||||
|
||||
[](https://github.com/ccbikai/loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong)
|
||||
|
||||
This little toy was finished last week. Just a few lines of code.
|
||||
|
||||
Encountered many issues during deployment, mainly related to HTTPS certificates.
|
||||
|
||||
The longest segment of the domain name is 63 characters. The commonName of the HTTPS certificate can be up to 64 characters.
|
||||
|
||||
This caused Cloudflare, Vercel, and Netlify to be unable to use Let's Encrypt to sign HTTPS certificates (because they use the domain name in commonName), but Zeabur can use Let's Encrypt to sign HTTPS certificates.
|
||||
|
||||
Finally, switching the Cloudflare certificate to Google Trust Services LLC successfully signed the certificate.
|
||||
|
||||
You can view the relevant certificates at [https://crt.sh/?q=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong](https://crt.sh/?q=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong).
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Sink - A short link system based on Cloudflare with visit statistics
|
||||
description: A short link system based on Cloudflare with visit statistics
|
||||
dateFormatted: Jun 4th, 2024
|
||||
---
|
||||
|
||||
I previously shared some websites on [Twitter](https://x.com/0xKaiBi) using short links to make it easier to see if people are interested. Among these link shortening systems, Dub provides the best user experience, but it has a fatal flaw: once the monthly clicks exceed 1000, you can no longer view the statistics.
|
||||
|
||||
While surfing the internet at home during the Qingming Festival, I discovered that [Cloudflare Workers Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/) supports data writing and API data querying. So, I created an MVP version myself, capable of handling statistics for up to 3,000,000 visits per month. Cloudflare's backend likely uses Clickhouse, so performance shouldn't be a significant issue.
|
||||
|
||||
During the Labor Day holiday, I improved the frontend UI at home and used it for about half a month, finding it satisfactory. I have open-sourced it for everyone to use.
|
||||
|
||||
## Features
|
||||
|
||||
- Link shortening
|
||||
- Visit statistics
|
||||
- Serverless deployment
|
||||
- Custom Slug
|
||||
- 🪄 AI-generated Slug
|
||||
- Link expiration
|
||||
|
||||
## Demo
|
||||
|
||||
[Sink.Cool](https://sink.cool/dashboard)
|
||||
|
||||
Site Token: `SinkCool`
|
||||
|
||||
### Site-wide Analysis
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary><b>Link Management</b></summary>
|
||||
<img alt="Link Management" src="https://static.miantiao.me/share/uQVX7Q/sink.cool_dashboard_links.png"/>
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Individual Link Analysis</b></summary>
|
||||
<img alt="Individual Link Analysis" src="https://static.miantiao.me/share/WfyCXT/sink.cool_dashboard_link_slug=0.png"/>
|
||||
</details>
|
||||
|
||||
## Open Source
|
||||
|
||||
[](https://github.com/ccbikai/sink)
|
||||
|
||||
## Roadmap (WIP)
|
||||
|
||||
- Browser extension
|
||||
- Raycast extension
|
||||
- Apple Shortcuts
|
||||
- Enhanced link management (based on Cloudflare D1)
|
||||
- Enhanced analysis (support filtering)
|
||||
- Panel performance optimization (support infinite loading)
|
||||
- Support for other platforms (maybe)
|
||||
|
||||
---
|
||||
|
||||
Finally, feel free to follow me on [Twitter](https://x.com/0xKaiBi) for updates on development progress and to share some web development news.
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Resolving Umami Blocked by AdBlock Issue
|
||||
description: Resolving Umami Blocked by AdBlock Issue
|
||||
dateFormatted: Jan 6th, 2024
|
||||
---
|
||||
|
||||
I recently redesigned my [personal homepage](https://mt.ci/) and used Umami for website analytics. However, there is an ongoing issue: users who have AdBlock installed are causing the analytics to fail.
|
||||
|
||||
For more information on how AdBlock works, you can refer to [Resolving Vercel Analytics Blocked by AdBlock Issue](11). The rule that blocks Umami is `||umami.is^$3p`, which blocks the script and data reporting URLs. To overcome this, we can use [Cloudflare Workers](https://workers.cloudflare.com/) to proxy Umami.
|
||||
|
||||

|
||||
|
||||
## Solution
|
||||
|
||||
Create a Cloudflare Worker and paste the following JavaScript code. If you are using the official Umami service, you don't need to modify the code (remember to change UMAMI\_HOST to your service URL). If you are using a self-hosted service, you can define the script and data reporting URLs using the `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` environment variables, without the need for proxying.
|
||||
|
||||
```js
|
||||
const UMAMI_HOST = 'https://eu.umami.is'
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const { pathname, search } = new URL(request.url)
|
||||
if (pathname.endsWith('.js')) {
|
||||
let response = await caches.default.match(request)
|
||||
if (!response) {
|
||||
response = await fetch(`${UMAMI_HOST}/script.js`, request)
|
||||
ctx.waitUntil(caches.default.put(request, response.clone()))
|
||||
}
|
||||
return response
|
||||
}
|
||||
const req = new Request(request)
|
||||
req.headers.delete("cookie")
|
||||
req.headers.append('x-client-ip', req.headers.get('cf-connecting-ip'))
|
||||
return fetch(`${UMAMI_HOST}${pathname}${search}`, req)
|
||||
},
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
Once you have created the Worker, configure the domain and test if the script URL can be accessed correctly. In my case, it is [https://ums.miantiao.me/mt-demo.js](https://ums.miantiao.me/mt-demo.js). You can replace "mt-demo" with any disguised URL, as the script has already been adapted.
|
||||
|
||||
Next, inject the script into your website project. You can refer to the official documentation at [https://umami.is/docs/tracker-configuration](https://umami.is/docs/tracker-configuration) or use the following code as a reference:
|
||||
|
||||
```html
|
||||
<script defer src="https://ums.miantiao.me/mt-demo.js" data-host-url="https://ums.miantiao.me" data-website-id="0a10de75-03be-4fec-a521-4c62b91650ac"></script>
|
||||
|
||||
```
|
||||
|
||||
In the above code, `src` refers to the script URL, `data-host-url` refers to the data reporting URL, and `data-website-id` refers to the website ID. Make sure to provide the correct website ID to ensure data reporting.
|
||||
|
||||
You can verify the implementation on [Noodle Lab](https://mt.ci/) or this website.
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Using Vercel Edge to Process Images
|
||||
description: Using Vercel Edge to Process Images
|
||||
dateFormatted: Dec 17th, 2023
|
||||
---
|
||||
|
||||
Previously, I shared an article on [using Cloudflare Worker to process images](https://dev.to/ccbikai/shi-yong-cloudflare-worker-chu-li-tu-pian-38dl-temp-slug-7437591). However, due to the limitations of the free version of Worker, which only allows for 10ms of CPU usage, there were frequent resource overages and high failure rates. Today, I had some free time, so I decided to try using Vercel Edge instead and share my findings with those who are interested.
|
||||
|
||||
The official version of Vercel also supports image processing, but it has a limit of 1000 original images per month and only supports scaling. By using Vercel Edge to process images, you can have additional features such as scaling, cropping, watermarking, and filters. However, please note that the free version of Vercel only allows for 100GB of monthly traffic, so it is recommended to use it in conjunction with a CDN for actual usage.
|
||||
|
||||
Supported features:
|
||||
|
||||
1. Support for processing PNG, JPG, BMP, ICO, and TIFF format images
|
||||
2. Output images in JPG, PNG, and WEBP formats, with WEBP being the default
|
||||
3. Support for pipelining, allowing for multiple operations to be performed
|
||||
4. Support for whitelisting image URLs to prevent abuse
|
||||
5. Graceful degradation in case of processing failure, returning the original image (exceptions are not cached)
|
||||
|
||||
## Demo
|
||||
|
||||
### Format Conversion
|
||||
|
||||
#### WEBP
|
||||
|
||||

|
||||
|
||||
#### JPG
|
||||
|
||||

|
||||
|
||||
#### PNG
|
||||
|
||||

|
||||
|
||||
### Scaling
|
||||
|
||||

|
||||
|
||||
### Rotation
|
||||
|
||||

|
||||
|
||||
### Cropping
|
||||
|
||||

|
||||
|
||||
### Filters
|
||||
|
||||

|
||||
|
||||
### Image Watermark
|
||||
|
||||

|
||||
|
||||
### Text Watermark
|
||||
|
||||

|
||||
|
||||
### Pipelining
|
||||
|
||||
#### Scaling + Rotation + Text Watermark
|
||||
|
||||

|
||||
|
||||
#### Scaling + Image Watermark
|
||||
|
||||

|
||||
|
||||
In theory, it supports various operations available in Photon. If you are interested, you can check the image URLs and modify the parameters according to the [Photon documentation](https://docs.rs/photon-rs/latest/photon_rs/) to try it out yourself. If you encounter any issues, please leave a comment and provide feedback.
|
||||
|
||||
## Sharing
|
||||
|
||||
I have open-sourced this solution on my GitHub repository, and you can deploy it by following the documentation.
|
||||
|
||||
[](https://github.com/ccbikai/vercel-edge-image)
|
||||
|
||||
* * *
|
||||
|
||||
[](https://www.buymeacoffee.com/ccbikai)
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
layout: ../../layouts/post.astro
|
||||
title: Solving Vercel Analytics Blocked by AdBlock Issue
|
||||
description: Solving Vercel Analytics Blocked by AdBlock Issue
|
||||
dateFormatted: Jun 6th, 2024
|
||||
---
|
||||
|
||||
[DNS.Surf](https://dns.surf/) runs 100% on Vercel, so Vercel Analytics is used for access statistics. However, many users who have AdBlock installed experience issues with access statistics not being recorded. Today, we will solve the problem of AdBlock blocking access statistics, while still relying on Vercel 100%.
|
||||
|
||||
The core principle of AdBlock is to block certain network requests and page elements using rules. Vercel Analytics is blocked by the rule `/_vercel/insights/script.js`, and it may also block `/_vercel/insights/event`. To solve this problem, we just need to make these two URLs less recognizable.
|
||||
|
||||

|
||||
|
||||
## Solution
|
||||
|
||||
Vercel comes with a Rewrite feature, so we just need to rewrite the disguised path `/mt-demo` to `/_vercel/insights`. The disguised path can be any unique path that does not conflict with existing paths. If it gets blocked, just use a different one. The vercel.json configuration is as follows:
|
||||
|
||||
```js
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/mt-demo/:match*",
|
||||
"destination": "https://dns.surf/_vercel/insights/:match*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note that the destination should be the complete URL, otherwise it will not work.
|
||||
|
||||
In the official tutorial, different frameworks use [@vercel/analytics](https://vercel.com/docs/analytics/package) to inject the analytics script into the page, but it does not support custom scripts and data reporting URLs. Therefore, we need to use the HTML method to inject the script.
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
|
||||
</script>
|
||||
<script async src="/mt-demo/script.js" data-endpoint="/mt-demo"></script>
|
||||
```
|
||||
|
||||
`src` is the script URL, and `data-endpoint` is the data reporting URL. Although it is not mentioned in the official documentation, the script does support it. Remember to replace `mt-demo` with your disguised path.
|
||||
|
||||
If you are using a different framework, you can look for the method to inject scripts in that framework to adapt it to your own usage.
|
||||
|
||||
You can verify the effect using [DNS.Surf](https://dns.surf/).
|
||||
37
src/data/experiences.json
Normal file
37
src/data/experiences.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
[
|
||||
{
|
||||
"dates": "2023 · Présent",
|
||||
"role": "Consultant Développeur Senior",
|
||||
"company": "Urssaf Caisse nationale",
|
||||
"description": "Mission freelance - Architecture et développement d'applications métier.",
|
||||
"logo": ""
|
||||
},
|
||||
{
|
||||
"dates": "2018 · Présent",
|
||||
"role": "Organisateur",
|
||||
"company": "Software Crafters Albi",
|
||||
"description": "Animation de la communauté locale de développeurs passionnés par le craft.",
|
||||
"logo": ""
|
||||
},
|
||||
{
|
||||
"dates": "2015 · 2016",
|
||||
"role": "Fondateur",
|
||||
"company": "while42",
|
||||
"description": "Réseau mondial de développeurs expatriés - communauté d'entraide et de partage.",
|
||||
"logo": ""
|
||||
},
|
||||
{
|
||||
"dates": "2003 · 2006",
|
||||
"role": "Créateur",
|
||||
"company": "Projets personnels",
|
||||
"description": "N.Gine (CMS propriétaire), ICU (plateforme de partage photo), Débats.co (débat politique collaboratif).",
|
||||
"logo": ""
|
||||
},
|
||||
{
|
||||
"dates": "2001 · 2003",
|
||||
"role": "Étudiant",
|
||||
"company": "UVSQ - Université de Versailles",
|
||||
"description": "Meilleur projet de programmation de l'année 2003.",
|
||||
"logo": ""
|
||||
}
|
||||
]
|
||||
8
src/data/favorites.json
Normal file
8
src/data/favorites.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
"portraits/Eroll.jpg",
|
||||
"places/Court-of-Audit-Paris.jpg",
|
||||
"places/Petra.jpg",
|
||||
"places/Au-pied-de-la-Tour-Eiffel.jpg",
|
||||
"places/In-Ceutas-fog.jpg",
|
||||
"places/Les-Houches.jpg"
|
||||
]
|
||||
10
src/data/menu.json
Normal file
10
src/data/menu.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
{
|
||||
"name": "Photo",
|
||||
"url": "/photo"
|
||||
},
|
||||
{
|
||||
"name": "À propos",
|
||||
"url": "/a-propos"
|
||||
}
|
||||
]
|
||||
138
src/data/menu.ts
Normal file
138
src/data/menu.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
export interface MenuItem {
|
||||
name: Record<string, string>;
|
||||
url: string;
|
||||
icon?: string;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
export const mainMenu: MenuItem[] = [
|
||||
{
|
||||
name: {
|
||||
fr: "Accueil",
|
||||
en: "Home",
|
||||
ar: "الرئيسية"
|
||||
},
|
||||
url: "/"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Pro",
|
||||
en: "Pro",
|
||||
ar: "مهني"
|
||||
},
|
||||
url: "/pro",
|
||||
children: [
|
||||
{
|
||||
name: {
|
||||
fr: "Blog Pro",
|
||||
en: "Pro Blog",
|
||||
ar: "مدونة مهنية"
|
||||
},
|
||||
url: "/blog/pro"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Projets",
|
||||
en: "Projects",
|
||||
ar: "مشاريع"
|
||||
},
|
||||
url: "/projects"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Talks",
|
||||
en: "Talks",
|
||||
ar: "محاضرات"
|
||||
},
|
||||
url: "/talks"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Comédie",
|
||||
en: "Comedy",
|
||||
ar: "كوميديا"
|
||||
},
|
||||
url: "/comedy",
|
||||
children: [
|
||||
{
|
||||
name: {
|
||||
fr: "Blog Comédie",
|
||||
en: "Comedy Blog",
|
||||
ar: "مدونة الكوميديا"
|
||||
},
|
||||
url: "/blog/comedy"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Spectacles",
|
||||
en: "Shows",
|
||||
ar: "عروض"
|
||||
},
|
||||
url: "/shows"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Photo",
|
||||
en: "Photo",
|
||||
ar: "تصوير"
|
||||
},
|
||||
url: "/photo",
|
||||
children: [
|
||||
{
|
||||
name: {
|
||||
fr: "Galerie",
|
||||
en: "Gallery",
|
||||
ar: "معرض"
|
||||
},
|
||||
url: "/gallery"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Blog Photo",
|
||||
en: "Photo Blog",
|
||||
ar: "مدونة التصوير"
|
||||
},
|
||||
url: "/blog/photo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Blog",
|
||||
en: "Blog",
|
||||
ar: "مدونة"
|
||||
},
|
||||
url: "/blog"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "À propos",
|
||||
en: "About",
|
||||
ar: "حول"
|
||||
},
|
||||
url: "/about"
|
||||
}
|
||||
];
|
||||
|
||||
export const slashPages: MenuItem[] = [
|
||||
{
|
||||
name: {
|
||||
fr: "Maintenant",
|
||||
en: "Now",
|
||||
ar: "الآن"
|
||||
},
|
||||
url: "/now"
|
||||
},
|
||||
{
|
||||
name: {
|
||||
fr: "Utilise",
|
||||
en: "Uses",
|
||||
ar: "يستخدم"
|
||||
},
|
||||
url: "/uses"
|
||||
}
|
||||
];
|
||||
|
|
@ -10,11 +10,5 @@
|
|||
"description": "Querying DNS Resolution Results in Different Regions Worldwide.",
|
||||
"image": "/assets/images/projects/dns.surf.png",
|
||||
"url": "https://dns.surf"
|
||||
},
|
||||
{
|
||||
"name": "HTML.ZONE",
|
||||
"description": "Web Toolbox.",
|
||||
"image": "/assets/images/projects/html.zone.png",
|
||||
"url": "https://html.zone"
|
||||
}
|
||||
]
|
||||
44
src/layouts/PhotoLayout.astro
Normal file
44
src/layouts/PhotoLayout.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
const { title = "Galerie Photo - Jalil Arfaoui", enableScroll = false, hideFooter = false } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
|
||||
<!-- Google Fonts - Karla -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Dark mode script (appliqué directement pour la galerie) -->
|
||||
<script is:inline>
|
||||
document.documentElement.classList.add('dark')
|
||||
</script>
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
|
||||
<Fragment set:html={import.meta.env.HEADER_INJECT} />
|
||||
</head>
|
||||
<body class={`antialiased bg-black text-white ${enableScroll ? '' : 'overflow-hidden'}`} style="font-family: 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||
<slot />
|
||||
|
||||
<!-- Footer bandeau -->
|
||||
<footer class="fixed bottom-0 left-0 right-0 z-40 bg-black/30 backdrop-blur-sm transition-transform duration-300">
|
||||
<div class="flex justify-between items-center w-full" style="padding-left: 20px; padding-right: 20px; height: 54px; line-height: 54px;">
|
||||
<div class="footer-left flex items-center text-white/70" style="font-size: 16px; font-weight: normal;">
|
||||
<a href="/a-propos" class="hover:text-white transition-colors" style="margin-right: 15px;">À propos</a>
|
||||
<a href="mailto:jalil@arfaoui.net" class="hover:text-white transition-colors" style="margin-right: 15px;">Contact</a>
|
||||
<a href="https://instagram.com/l.i.l.a.j" target="_blank" rel="noopener noreferrer" class="hover:text-white transition-colors">Instagram</a>
|
||||
</div>
|
||||
<div class="footer-right text-white/70" style="font-size: 16px; font-weight: normal;">
|
||||
© Jalil Arfaoui Creative Commons CC-BY-NC 4.0
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<Fragment set:html={import.meta.env.FOOTER_INJECT} />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -29,7 +29,7 @@ const { title } = Astro.props;
|
|||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" type="image/x-icon" href="../assets/images/favicon.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
|
||||
<script src="../assets/css/main.css"></script>
|
||||
<Fragment set:html={import.meta.env.HEADER_INJECT} />
|
||||
</head>
|
||||
|
|
|
|||
54
src/pages/a-propos.astro
Normal file
54
src/pages/a-propos.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
import PageHeading from "../components/page-heading.astro";
|
||||
import Layout from "../layouts/main.astro";
|
||||
import Link from "../components/Link.astro";
|
||||
---
|
||||
|
||||
<Layout title="À propos - Jalil Arfaoui">
|
||||
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
|
||||
<PageHeading
|
||||
title="À propos"
|
||||
description="Artisan du code, passionné de théâtre, amateur de photographie."
|
||||
/>
|
||||
|
||||
<img src="/assets/images/photo.png" class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
|
||||
|
||||
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">Qui suis-je ?</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
Je suis <strong class="text-gray-800 dark:text-neutral-200">Jalil Arfaoui</strong>, développeur freelance basé à Albi avec plus de 20 ans d'expérience.
|
||||
Mon credo : <em>"Construire des logiciels bien pensés qui répondent à de vrais besoins"</em>.
|
||||
</p>
|
||||
<p>
|
||||
Passionné par le <strong class="text-gray-800 dark:text-neutral-200">Software Craftsmanship</strong>, je pratique le TDD, le Clean Code et le Domain-Driven Design au quotidien.
|
||||
J'accompagne les équipes en tant que développeur senior, tech lead ou coach technique selon les besoins.
|
||||
</p>
|
||||
<p>
|
||||
Mon stack de prédilection : <strong class="text-gray-800 dark:text-neutral-200">TypeScript/JavaScript</strong>, mais aussi PHP et Elixir.
|
||||
Ce qui m'anime : construire des architectures propres et maintenables, et transmettre ces pratiques.
|
||||
</p>
|
||||
<p>
|
||||
Organisateur des Software Crafters Albi depuis 2018, j'aime rassembler et partager avec d'autres passionnés du code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Au-delà du code</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
🎭 <strong class="text-gray-800 dark:text-neutral-200">Théâtre</strong> — Amateur passionné, je monte sur scène régulièrement.
|
||||
Le théâtre m'apporte une autre façon de raconter des histoires et de me connecter aux autres.
|
||||
</p>
|
||||
<p>
|
||||
📸 <strong class="text-gray-800 dark:text-neutral-200">Photographie</strong> — Capturer l'instant, jouer avec la lumière.
|
||||
Une passion qui me suit depuis des années et que je partage <Link href="/photo">sur ce site</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Me contacter</h2>
|
||||
<p class="text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
Envie d'échanger sur un projet, une mission ou juste discuter craft ?
|
||||
<br class="hidden sm:block" />
|
||||
· <Link href="mailto:jalil@arfaoui.net">Écrivez-moi</Link>
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
---
|
||||
import experiences from "../collections/experiences.json";
|
||||
import AboutExperience from "../components/about-experience.astro";
|
||||
import PageHeading from "../components/page-heading.astro";
|
||||
import Layout from "../layouts/main.astro";
|
||||
---
|
||||
|
||||
<Layout title="About Me">
|
||||
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
|
||||
<PageHeading
|
||||
title="About Me"
|
||||
description="Hello 👋 I'm a frontend engineer from Nanjing, China. I'm passionate about building new products and learning new technology."
|
||||
/>
|
||||
|
||||
<img src="/assets/images/about.jpg" class="relative z-30 w-full my-10 rounded-xl" />
|
||||
|
||||
<h2 class="mb-2 text-2xl font-bold dark:text-neutral-200">Short Bio</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-gray-600 dark:text-neutral-400 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
|
||||
>
|
||||
Front-end cutter 🧑🏻💻, back-end amateur 🤷🏻♂️, operations digging holes person 🤦🏻♂️.
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="mt-5 mb-2 text-2xl font-bold lg:mt-10 sm:mt-6 dark:text-neutral-200"
|
||||
>
|
||||
Experience
|
||||
</h2>
|
||||
<div class="px-5 py-10">
|
||||
{
|
||||
experiences.map((experience, loop) => {
|
||||
return (
|
||||
<>
|
||||
{loop == 0 || loop == 1 ? (
|
||||
<div class="pb-10 border-l border-gray-200 dark:border-neutral-700">
|
||||
<AboutExperience
|
||||
dates={experience.dates}
|
||||
role={experience.role}
|
||||
company={experience.company}
|
||||
description={experience.description}
|
||||
logo={experience.logo}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AboutExperience
|
||||
dates={experience.dates}
|
||||
role={experience.role}
|
||||
company={experience.company}
|
||||
description={experience.description}
|
||||
logo={experience.logo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 mb-2 text-2xl font-bold lg:mt-10 sm:mt-6 dark:text-neutral-200">Let's Connect</h2>
|
||||
<p
|
||||
class="text-sm leading-6 text-gray-600 dark:text-neutral-400 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
|
||||
>
|
||||
If you want to stay up to date with my work be sure to <a
|
||||
href="https://twitter.com/ccikai"
|
||||
target="_blank"
|
||||
class="text-indigo-600 underline">follow me on twitter</a
|
||||
>, or you can send me an <a href="mailto:astro-aria#miantiao.me" class="text-indigo-600 underline"
|
||||
>email</a
|
||||
> and I'll be sure to get back to you.
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
181
src/pages/ar/index.astro
Normal file
181
src/pages/ar/index.astro
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
---
|
||||
import Layout from "../../layouts/main.astro";
|
||||
---
|
||||
|
||||
<Layout title="جليل عرفاوي - مطوّر • ممثل • مصوّر">
|
||||
<!-- Hero Section -->
|
||||
<div dir="rtl" lang="ar" class="relative z-20 w-full max-w-6xl mx-auto mt-16 px-7 md:mt-24 lg:mt-32 xl:px-0">
|
||||
<div class="flex flex-col items-center md:flex-row-reverse">
|
||||
<div class="relative w-full md:w-1/2">
|
||||
<h1 class="mb-5 text-5xl font-bold leading-tight md:text-6xl lg:text-7xl dark:text-white">
|
||||
جليل عرفاوي
|
||||
</h1>
|
||||
<h2 class="mb-6 text-xl font-medium text-neutral-600 dark:text-neutral-300 md:text-2xl">
|
||||
مطوّر • عاشق للمسرح • هاوي للتصوير
|
||||
</h2>
|
||||
<p class="mb-8 text-lg text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
مرحبًا بكم في عالمي الإبداعي حيث يلتقي الكود بالفن
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="قيد الإنشاء">
|
||||
اكتشف أعمالي
|
||||
<span class="mr-2">🚧</span>
|
||||
</span>
|
||||
<a href="/ar/نبذة-عني" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
|
||||
تعرّف عليّ أكثر
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative justify-end w-full mt-12 md:flex md:pr-10 md:w-1/2 md:mt-0">
|
||||
<div class="relative z-50 w-full max-w-sm mx-auto">
|
||||
<div class="absolute top-6 left-6 z-40 w-20 h-20 rounded-full bg-gradient-to-br from-blue-400 to-purple-600 animate-pulse"></div>
|
||||
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
|
||||
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
|
||||
<img
|
||||
src="/assets/images/photo.png"
|
||||
alt="جليل عرفاوي"
|
||||
loading="eager"
|
||||
decoding="auto"
|
||||
class="w-full aspect-square object-cover rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Universe Cards Section -->
|
||||
<div dir="rtl" lang="ar" class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Professional Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/30 dark:to-indigo-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-blue-200/50 dark:border-blue-800/30">
|
||||
<div class="absolute top-4 left-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">💻</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-blue-900 dark:text-blue-100 mb-3">
|
||||
المطوّر
|
||||
</h3>
|
||||
<p class="text-blue-700 dark:text-blue-300 mb-6 leading-relaxed">
|
||||
شغوف بحرفة البرمجة، TDD والهندسة النظيفة. خبير في TypeScript و Node.js و DevOps.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← المسار المهني 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← مشاريعي 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← المدوّنة التقنية 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theater Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-yellow-50 to-orange-100 dark:from-yellow-950/30 dark:to-orange-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-yellow-200/50 dark:border-yellow-800/30">
|
||||
<div class="absolute top-4 left-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">🎭</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-orange-900 dark:text-orange-100 mb-3">
|
||||
المسرح
|
||||
</h3>
|
||||
<p class="text-orange-700 dark:text-orange-300 mb-6 leading-relaxed">
|
||||
عاشق للمسرح ومبدع للمحتوى الفكاهي. من خشبة المسرح إلى وسائل التواصل الاجتماعي، استكشاف فن الإضحاك.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← المسار الفني 🚧
|
||||
</span>
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← عروضي 🚧
|
||||
</span>
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← مدوّنة المسرح 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photography Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-purple-50 to-pink-100 dark:from-purple-950/30 dark:to-pink-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-purple-200/50 dark:border-purple-800/30">
|
||||
<div class="absolute top-4 left-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">📸</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-purple-900 dark:text-purple-100 mb-3">
|
||||
التصوير
|
||||
</h3>
|
||||
<p class="text-purple-700 dark:text-purple-300 mb-6 leading-relaxed">
|
||||
هاوي تصوير فوتوغرافي، ألتقط اللحظات وأروي القصص من خلال العدسة.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
|
||||
← معرض الصور
|
||||
</a>
|
||||
<span class="block text-purple-400/50 dark:text-purple-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← الألبومات 🚧
|
||||
</span>
|
||||
<span class="block text-purple-400/50 dark:text-purple-600/50 cursor-not-allowed" title="قيد الإنشاء">
|
||||
← مدوّنة التصوير 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Section -->
|
||||
<div dir="rtl" lang="ar" class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
آخر المقالات
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
اكتشف أفكاري حول البرمجة والمسرح والتصوير
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="inline-flex items-center px-8 py-4 text-lg font-semibold text-white/60 bg-gradient-to-r from-blue-600/50 via-purple-600/50 to-pink-600/50 rounded-full cursor-not-allowed"
|
||||
title="قيد الإنشاء"
|
||||
>
|
||||
اكتشف جميع المقالات
|
||||
<span class="mr-2">🚧</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div dir="rtl" lang="ar" class="relative z-20 w-full max-w-6xl mx-auto mt-24 mb-16 px-7 xl:px-0">
|
||||
<div class="bg-gradient-to-r from-neutral-50 to-neutral-100 dark:from-neutral-900 dark:to-neutral-800 rounded-3xl p-12 text-center border border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-2xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
لنبقَ على تواصل
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-8 max-w-2xl mx-auto">
|
||||
سواء لمشروع مهني، أو تعاون إبداعي، أو مجرد حديث، لا تتردد في التواصل!
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center space-x-6 space-x-reverse">
|
||||
<a href="https://linkedin.com/in/jalil" class="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/jalilarfaoui" class="p-3 bg-neutral-800 text-white rounded-full hover:bg-neutral-900 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<a href="mailto:contact@jalil.arfaoui.net" class="p-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
54
src/pages/ar/نبذة-عني.astro
Normal file
54
src/pages/ar/نبذة-عني.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
import PageHeading from "../../components/page-heading.astro";
|
||||
import Layout from "../../layouts/main.astro";
|
||||
import Link from "../../components/Link.astro";
|
||||
---
|
||||
|
||||
<Layout title="نبذة عني - جليل عرفاوي">
|
||||
<section dir="rtl" lang="ar" class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
|
||||
<PageHeading
|
||||
title="نبذة عني"
|
||||
description="حرفي في البرمجة، عاشق للمسرح، هاوي للتصوير الفوتوغرافي."
|
||||
/>
|
||||
|
||||
<img src="/assets/images/photo.png" class="relative z-30 w-full my-10 rounded-xl" alt="جليل عرفاوي" />
|
||||
|
||||
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">من أنا؟</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
أنا <strong class="text-gray-800 dark:text-neutral-200">جليل عرفاوي</strong>، مطوّر برمجيات مستقل مقيم في ألبي بفرنسا، بخبرة تتجاوز عشرين عامًا.
|
||||
شعاري: <em>"بناء برمجيات مدروسة تلبي احتياجات حقيقية"</em>.
|
||||
</p>
|
||||
<p>
|
||||
شغوف بـ<strong class="text-gray-800 dark:text-neutral-200">حرفة البرمجة</strong> (Software Craftsmanship)، أمارس يوميًا منهجيات TDD والكود النظيف وتصميم المجالات.
|
||||
أرافق الفرق كمطوّر أول، أو قائد تقني، أو مدرب تقني حسب الحاجة.
|
||||
</p>
|
||||
<p>
|
||||
تقنياتي المفضلة: <strong class="text-gray-800 dark:text-neutral-200">TypeScript/JavaScript</strong>، بالإضافة إلى PHP و Elixir.
|
||||
ما يحرّكني: بناء هياكل برمجية نظيفة وقابلة للصيانة، ونقل هذه الممارسات للآخرين.
|
||||
</p>
|
||||
<p>
|
||||
منظّم مجتمع Software Crafters Albi منذ 2018، أحب الجمع والمشاركة مع المتحمسين للبرمجة.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">ما وراء الكود</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
🎭 <strong class="text-gray-800 dark:text-neutral-200">المسرح</strong> — هاوٍ شغوف، أصعد على خشبة المسرح بانتظام.
|
||||
المسرح يمنحني طريقة أخرى لسرد القصص والتواصل مع الناس.
|
||||
</p>
|
||||
<p>
|
||||
📸 <strong class="text-gray-800 dark:text-neutral-200">التصوير الفوتوغرافي</strong> — التقاط اللحظة، اللعب بالضوء.
|
||||
شغف يرافقني منذ سنوات، أشاركه <Link href="/photo">على هذا الموقع</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">تواصل معي</h2>
|
||||
<p class="text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
تريد مناقشة مشروع، مهمة، أو مجرد الحديث عن البرمجة؟
|
||||
<br class="hidden sm:block" />
|
||||
· <Link href="mailto:jalil@arfaoui.net">راسلني</Link>
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
54
src/pages/en/about.astro
Normal file
54
src/pages/en/about.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
import PageHeading from "../../components/page-heading.astro";
|
||||
import Layout from "../../layouts/main.astro";
|
||||
import Link from "../../components/Link.astro";
|
||||
---
|
||||
|
||||
<Layout title="About - Jalil Arfaoui">
|
||||
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
|
||||
<PageHeading
|
||||
title="About"
|
||||
description="Code craftsman, theater enthusiast, photography lover."
|
||||
/>
|
||||
|
||||
<img src="/assets/images/photo.png" class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
|
||||
|
||||
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">Who am I?</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
I'm <strong class="text-gray-800 dark:text-neutral-200">Jalil Arfaoui</strong>, a freelance developer based in Albi, France with over 20 years of experience.
|
||||
My motto: <em>"Building well-thought software that meets real needs"</em>.
|
||||
</p>
|
||||
<p>
|
||||
Passionate about <strong class="text-gray-800 dark:text-neutral-200">Software Craftsmanship</strong>, I practice TDD, Clean Code and Domain-Driven Design on a daily basis.
|
||||
I support teams as a senior developer, tech lead or technical coach depending on their needs.
|
||||
</p>
|
||||
<p>
|
||||
My preferred stack: <strong class="text-gray-800 dark:text-neutral-200">TypeScript/JavaScript</strong>, but also PHP and Elixir.
|
||||
What drives me: building clean, maintainable architectures and sharing these practices with others.
|
||||
</p>
|
||||
<p>
|
||||
Organizer of Software Crafters Albi since 2018, I love bringing together and sharing with fellow code enthusiasts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Beyond code</h2>
|
||||
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
<p>
|
||||
🎭 <strong class="text-gray-800 dark:text-neutral-200">Theater</strong> — Passionate amateur actor, I regularly perform on stage.
|
||||
Theater gives me another way to tell stories and connect with people.
|
||||
</p>
|
||||
<p>
|
||||
📸 <strong class="text-gray-800 dark:text-neutral-200">Photography</strong> — Capturing the moment, playing with light.
|
||||
A passion that has followed me for years, which I share <Link href="/photo">on this site</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Get in touch</h2>
|
||||
<p class="text-gray-600 dark:text-neutral-400 leading-relaxed">
|
||||
Want to discuss a project, a mission, or just talk craft?
|
||||
<br class="hidden sm:block" />
|
||||
· <Link href="mailto:jalil@arfaoui.net">Email me</Link>
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
181
src/pages/en/index.astro
Normal file
181
src/pages/en/index.astro
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
---
|
||||
import Layout from "../../layouts/main.astro";
|
||||
---
|
||||
|
||||
<Layout title="Jalil Arfaoui - Developer • Actor • Photographer">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-16 px-7 md:mt-24 lg:mt-32 xl:px-0">
|
||||
<div class="flex flex-col items-center md:flex-row">
|
||||
<div class="relative w-full md:w-1/2">
|
||||
<h1 class="mb-5 text-5xl font-bold leading-tight md:text-6xl lg:text-7xl dark:text-white">
|
||||
Jalil Arfaoui
|
||||
</h1>
|
||||
<h2 class="mb-6 text-xl font-medium text-neutral-600 dark:text-neutral-300 md:text-2xl">
|
||||
Developer • Theater enthusiast • Photography lover
|
||||
</h2>
|
||||
<p class="mb-8 text-lg text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
Welcome to my creative universe where code meets art
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="Under construction">
|
||||
View my work
|
||||
<span class="ml-2">🚧</span>
|
||||
</span>
|
||||
<a href="/en/about" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative justify-end w-full mt-12 md:flex md:pl-10 md:w-1/2 md:mt-0">
|
||||
<div class="relative z-50 w-full max-w-sm mx-auto">
|
||||
<div class="absolute top-6 right-6 z-40 w-20 h-20 rounded-full bg-gradient-to-br from-blue-400 to-purple-600 animate-pulse"></div>
|
||||
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
|
||||
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
|
||||
<img
|
||||
src="/assets/images/photo.png"
|
||||
alt="Jalil Arfaoui"
|
||||
loading="eager"
|
||||
decoding="auto"
|
||||
class="w-full aspect-square object-cover rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Universe Cards Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Professional Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/30 dark:to-indigo-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-blue-200/50 dark:border-blue-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">💻</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Developer
|
||||
</h3>
|
||||
<p class="text-blue-700 dark:text-blue-300 mb-6 leading-relaxed">
|
||||
Passionate about Software Craftsmanship, TDD and clean architecture. TypeScript, Node.js and DevOps expert.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Professional journey 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ My projects 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Technical blog 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theater Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-yellow-50 to-orange-100 dark:from-yellow-950/30 dark:to-orange-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-yellow-200/50 dark:border-yellow-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">🎭</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-orange-900 dark:text-orange-100 mb-3">
|
||||
Theater
|
||||
</h3>
|
||||
<p class="text-orange-700 dark:text-orange-300 mb-6 leading-relaxed">
|
||||
Theater enthusiast and creator of humorous content. Exploring the art of making people laugh, from stage to social media.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Artistic journey 🚧
|
||||
</span>
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ My shows 🚧
|
||||
</span>
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Theater blog 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photography Universe -->
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-purple-50 to-pink-100 dark:from-purple-950/30 dark:to-pink-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-purple-200/50 dark:border-purple-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">📸</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-purple-900 dark:text-purple-100 mb-3">
|
||||
Photography
|
||||
</h3>
|
||||
<p class="text-purple-700 dark:text-purple-300 mb-6 leading-relaxed">
|
||||
Photography hobbyist capturing moments and telling stories through the lens.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
|
||||
→ Photo portfolio
|
||||
</a>
|
||||
<span class="block text-purple-400/50 dark:text-purple-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Galleries 🚧
|
||||
</span>
|
||||
<span class="block text-purple-400/50 dark:text-purple-600/50 cursor-not-allowed" title="Under construction">
|
||||
→ Photo blog 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
Latest articles
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
Discover my thoughts on development, theater and photography
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="inline-flex items-center px-8 py-4 text-lg font-semibold text-white/60 bg-gradient-to-r from-blue-600/50 via-purple-600/50 to-pink-600/50 rounded-full cursor-not-allowed"
|
||||
title="Under construction"
|
||||
>
|
||||
Discover all my articles
|
||||
<span class="ml-2">🚧</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 mb-16 px-7 xl:px-0">
|
||||
<div class="bg-gradient-to-r from-neutral-50 to-neutral-100 dark:from-neutral-900 dark:to-neutral-800 rounded-3xl p-12 text-center border border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-2xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
Let's stay in touch
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-8 max-w-2xl mx-auto">
|
||||
Whether for a professional project, creative collaboration or just to chat, feel free to reach out!
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center space-x-6">
|
||||
<a href="https://linkedin.com/in/jalil" class="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/jalilarfaoui" class="p-3 bg-neutral-800 text-white rounded-full hover:bg-neutral-900 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<a href="mailto:contact@jalil.arfaoui.net" class="p-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
|
@ -1,76 +1,169 @@
|
|||
---
|
||||
import Button from "../components/button.astro";
|
||||
import Projects from "../components/home/projects.astro";
|
||||
import Separator from "../components/home/separator.astro";
|
||||
import Writings from "../components/home/writings.astro";
|
||||
import Layout from "../layouts/main.astro";
|
||||
---
|
||||
|
||||
<Layout title="Kai">
|
||||
<div
|
||||
class="relative z-20 w-full max-w-4xl mx-auto mt-16 px-7 md:mt-24 lg:mt-32 xl:px-0"
|
||||
>
|
||||
<Layout title="Jalil Arfaoui - Développeur • Comédien • Photographe">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-16 px-7 md:mt-24 lg:mt-32 xl:px-0">
|
||||
<div class="flex flex-col items-center md:flex-row">
|
||||
<div class="relative w-full md:w-1/2">
|
||||
<h1
|
||||
class="mb-5 text-4xl font-bold leading-tight md:text-4xl lg:text-6xl dark:text-white"
|
||||
>
|
||||
Hello, I'm Kai.
|
||||
<h1 class="mb-5 text-5xl font-bold leading-tight md:text-6xl lg:text-7xl dark:text-white">
|
||||
Jalil Arfaoui
|
||||
</h1>
|
||||
<p class="mb-6 text-base text-neutral-600 dark:text-neutral-400">
|
||||
I'm a front-end programmer living in Nanjing. <br
|
||||
class="hidden lg:block"
|
||||
/>I focus on Web development.
|
||||
<h2 class="mb-6 text-xl font-medium text-neutral-600 dark:text-neutral-300 md:text-2xl">
|
||||
Développeur • Comédien • Photographe
|
||||
</h2>
|
||||
<p class="mb-8 text-lg text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||
Bienvenue dans mon univers créatif où le code rencontre l'art
|
||||
</p>
|
||||
<p class="mb-2 font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
I can help you out with:
|
||||
</p>
|
||||
<ul
|
||||
class="py-2 space-y-[3px] text-sm list-disc list-inside text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
<li>Vue.js Development</li>
|
||||
<li>React.js Development</li>
|
||||
<li>Node.js Development</li>
|
||||
<li>Website Design</li>
|
||||
<li>and more...</li>
|
||||
</ul>
|
||||
<Button text="Follow me on 𝕏" link="https://twitter.com/0xKaiBi" />
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="En construction">
|
||||
Voir mon travail
|
||||
<span class="ml-2">🚧</span>
|
||||
</span>
|
||||
<a href="/a-propos" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
|
||||
En savoir plus
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative justify-end hidden w-full mt-10 md:flex md:pl-10 md:w-1/2 md:mt-0 md:translate-y-4 xl:translate-y-0"
|
||||
>
|
||||
<div class="relative z-50 w-full">
|
||||
<div
|
||||
class="absolute bottom-0 z-40 w-16 h-16 -translate-x-6 -translate-y-1/2 lg:top-auto top-0 lg:-translate-y-[330px] rounded-full"
|
||||
>
|
||||
<span
|
||||
class="relative z-20 flex items-center justify-center w-full h-full text-2xl border-8 border-white rounded-full dark:border-neutral-950 bg-neutral-100 dark:bg-neutral-900"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-full h-full bg-white border border-dashed rounded-full dark:bg-neutral-950 border-neutral-300 dark:border-neutral-700"
|
||||
>👋</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative z-30 px-10">
|
||||
<img
|
||||
src="/assets/images/photo.png"
|
||||
loading="eager"
|
||||
decoding="auto"
|
||||
class="relative z-30 w-full aspect-[790/1189] md:max-w-md mx-auto dark:-translate-y-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 z-20 w-full h-full lg:h-[420px] translate-x-0 -translate-y-px border border-dashed rounded-2xl bg-gradient-to-r dark:from-neutral-950 dark:via-black dark:to-neutral-950 from-white via-neutral-50 to-white border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
|
||||
<div class="relative justify-end w-full mt-12 md:flex md:pl-10 md:w-1/2 md:mt-0">
|
||||
<div class="relative z-50 w-full max-w-sm mx-auto">
|
||||
<div class="absolute top-6 right-6 z-40 w-20 h-20 rounded-full bg-gradient-to-br from-blue-400 to-purple-600 animate-pulse"></div>
|
||||
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
|
||||
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
|
||||
<img
|
||||
src="/assets/images/photo.png"
|
||||
alt="Jalil Arfaoui"
|
||||
loading="eager"
|
||||
decoding="auto"
|
||||
class="w-full aspect-square object-cover rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator text="Check out my projects" />
|
||||
<Projects />
|
||||
<Separator text="Some of my writing" />
|
||||
<Writings />
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/30 dark:to-indigo-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-blue-200/50 dark:border-blue-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">💻</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Développeur
|
||||
</h3>
|
||||
<p class="text-blue-700 dark:text-blue-300 mb-6 leading-relaxed">
|
||||
Passionné par le Software Craftsmanship, le DDD et la Clean Architecture. Expert TypeScript et Node.js.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
|
||||
→ Parcours professionnel 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
|
||||
→ Mes projets 🚧
|
||||
</span>
|
||||
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
|
||||
→ Blog technique 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-yellow-50 to-orange-100 dark:from-yellow-950/30 dark:to-orange-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-yellow-200/50 dark:border-yellow-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">🎭</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-orange-900 dark:text-orange-100 mb-3">
|
||||
Théâtre
|
||||
</h3>
|
||||
<p class="text-orange-700 dark:text-orange-300 mb-6 leading-relaxed">
|
||||
Improvisateur passionné, je découvre aussi le théâtre écrit et le jeu face caméra.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="En construction">
|
||||
→ Parcours artistique 🚧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group relative overflow-hidden bg-gradient-to-br from-purple-50 to-pink-100 dark:from-purple-950/30 dark:to-pink-950/30 rounded-3xl p-8 hover:scale-105 transition-all duration-300 border border-purple-200/50 dark:border-purple-800/30">
|
||||
<div class="absolute top-4 right-4 text-4xl opacity-20 group-hover:opacity-40 transition-opacity">📸</div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="text-2xl font-bold text-purple-900 dark:text-purple-100 mb-3">
|
||||
Photographie
|
||||
</h3>
|
||||
<p class="text-purple-700 dark:text-purple-300 mb-6 leading-relaxed">
|
||||
Amateur passionné de photographie. Capturer l'instant, raconter une histoire à travers l'objectif.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
|
||||
→ Portfolio photo
|
||||
</a>
|
||||
<a href="/photo/blog" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
|
||||
→ Fil Photo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Section
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 px-7 xl:px-0">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
Derniers articles
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
Découvrez mes réflexions sur le développement, la comédie et la photographie
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="inline-flex items-center px-8 py-4 text-lg font-semibold text-white/60 bg-gradient-to-r from-blue-600/50 via-purple-600/50 to-pink-600/50 rounded-full cursor-not-allowed"
|
||||
title="En construction"
|
||||
>
|
||||
Découvrir tous mes articles
|
||||
<span class="ml-2">🚧</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="relative z-20 w-full max-w-6xl mx-auto mt-24 mb-16 px-7 xl:px-0">
|
||||
<div class="bg-gradient-to-r from-neutral-50 to-neutral-100 dark:from-neutral-900 dark:to-neutral-800 rounded-3xl p-12 text-center border border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-2xl font-bold text-neutral-800 dark:text-neutral-200 mb-4">
|
||||
Restons en contact
|
||||
</h2>
|
||||
<p class="text-neutral-600 dark:text-neutral-400 mb-8 max-w-2xl mx-auto">
|
||||
Que ce soit pour un projet professionnel, une collaboration créative ou simplement échanger, n'hésitez pas !
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center space-x-6">
|
||||
<a href="https://linkedin.com/in/jalil" target="_blank" rel="noopener noreferrer" class="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/jalilarfaoui" target="_blank" rel="noopener noreferrer" class="p-3 bg-neutral-800 text-white rounded-full hover:bg-neutral-900 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<a href="mailto:jalil@arfaoui.net" class="p-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
|
|
|||
10
src/pages/photo.astro
Normal file
10
src/pages/photo.astro
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
import PhotoLayout from '../layouts/PhotoLayout.astro';
|
||||
import PhotoGallery from '../components/photo/PhotoGallery.astro';
|
||||
|
||||
const title = "Galerie Photo - Jalil Arfaoui";
|
||||
---
|
||||
|
||||
<PhotoLayout title={title}>
|
||||
<PhotoGallery category="all" />
|
||||
</PhotoLayout>
|
||||
36
src/pages/photo/albums/[...category].astro
Normal file
36
src/pages/photo/albums/[...category].astro
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
|
||||
import CategoryGrid from '../../../components/photo/CategoryGrid.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = [
|
||||
'blog', 'portraits', 'places', 'nature',
|
||||
'cultures', 'music', 'sports', 'engines', 'everyday'
|
||||
];
|
||||
|
||||
return categories.map(category => ({
|
||||
params: { category },
|
||||
props: { category }
|
||||
}));
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
|
||||
const categoryLabels = {
|
||||
'blog': 'Blog',
|
||||
'portraits': 'Portraits',
|
||||
'places': 'Paysages',
|
||||
'nature': 'Nature',
|
||||
'cultures': 'Cultures',
|
||||
'music': 'Musique',
|
||||
'sports': 'Sports',
|
||||
'engines': 'Moteurs',
|
||||
'everyday': 'Quotidien'
|
||||
};
|
||||
|
||||
const title = `Galerie ${categoryLabels[category] || category} - Jalil Arfaoui`;
|
||||
---
|
||||
|
||||
<PhotoLayout title={title} enableScroll={true}>
|
||||
<CategoryGrid category={category} />
|
||||
</PhotoLayout>
|
||||
115
src/pages/photo/blog/[slug].astro
Normal file
115
src/pages/photo/blog/[slug].astro
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
|
||||
import CategoryNav from '../../../components/photo/CategoryNav.astro';
|
||||
import AlbumHeader from '../../../components/photo/AlbumHeader.astro';
|
||||
import MasonryGallery from '../../../components/photo/MasonryGallery.astro';
|
||||
import Lightbox from '../../../components/photo/Lightbox.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
// Importer toutes les images du dossier photos
|
||||
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/**/*.{jpg,jpeg,png,webp}');
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const allPhotoBlogPosts = await getCollection('photoBlogPosts');
|
||||
return allPhotoBlogPosts.map(post => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
|
||||
// coverImage est déjà un ImageMetadata grâce au schema image() dans config.ts
|
||||
const coverImage = post.data.coverImage;
|
||||
|
||||
// Charger toutes les images du dossier correspondant au slug
|
||||
const albumPath = `/src/assets/images/photos/blog/${post.slug}/`;
|
||||
const albumImages = Object.keys(allImages)
|
||||
.filter(path => path.startsWith(albumPath))
|
||||
.sort();
|
||||
|
||||
// Résoudre les images de la galerie
|
||||
const galleryImages = await Promise.all(
|
||||
albumImages.map(async (imagePath) => {
|
||||
const loader = allImages[imagePath];
|
||||
const img = await loader();
|
||||
const filename = imagePath.split('/').pop() || '';
|
||||
return {
|
||||
src: img.default,
|
||||
alt: filename.replace(/\.[^/.]+$/, '').replace(/-/g, ' ').replace(/^\d+-/, ''),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Données pour la lightbox
|
||||
const lightboxImages = galleryImages.map(img => ({
|
||||
src: img.src.src,
|
||||
alt: img.alt
|
||||
}));
|
||||
---
|
||||
|
||||
<PhotoLayout title={`${post.data.title} - Blog Photo - Jalil Arfaoui`} enableScroll={true} hideFooter={false}>
|
||||
<div class="album-container">
|
||||
<CategoryNav currentCategory="blog" opaque={false} />
|
||||
|
||||
<AlbumHeader
|
||||
title={post.data.title}
|
||||
description={post.data.description}
|
||||
date={new Date(post.data.date)}
|
||||
tags={post.data.tags}
|
||||
coverImage={coverImage}
|
||||
scrollTarget="#album-content"
|
||||
/>
|
||||
|
||||
<div id="album-content">
|
||||
{post.body && (
|
||||
<div class="post-content">
|
||||
<Content />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MasonryGallery images={galleryImages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Lightbox images={lightboxImages} albumTitle={post.data.title} />
|
||||
</PhotoLayout>
|
||||
|
||||
<style>
|
||||
.album-container {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
padding-top: var(--header-height, 53px);
|
||||
}
|
||||
|
||||
.post-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 40px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.post-content :global(h1),
|
||||
.post-content :global(h2),
|
||||
.post-content :global(h3) {
|
||||
margin: 2em 0 1em 0;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.post-content :global(p) {
|
||||
margin: 1.5em 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.post-content :global(img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 2em 0;
|
||||
}
|
||||
</style>
|
||||
318
src/pages/photo/blog/index.astro
Normal file
318
src/pages/photo/blog/index.astro
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
---
|
||||
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
|
||||
import CategoryNav from '../../../components/photo/CategoryNav.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
// Récupération des posts photo
|
||||
const allPhotoBlogPosts = await getCollection('photoBlogPosts');
|
||||
|
||||
// Tri par date (plus récent en premier)
|
||||
const sortedPosts = allPhotoBlogPosts.sort((a, b) =>
|
||||
new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
|
||||
);
|
||||
|
||||
// coverImage est déjà un ImageMetadata grâce au schema image() dans config.ts
|
||||
const postsWithImages = sortedPosts.map((post) => ({
|
||||
...post,
|
||||
resolvedCoverImage: post.data.coverImage
|
||||
}));
|
||||
|
||||
// Séparer les posts à la une des autres
|
||||
const featuredPosts = postsWithImages.filter(post => post.data.featured);
|
||||
const regularPosts = postsWithImages.filter(post => !post.data.featured);
|
||||
---
|
||||
|
||||
<PhotoLayout title="Blog Photo - Jalil Arfaoui" enableScroll={true} hideFooter={false}>
|
||||
<div class="blog-container">
|
||||
<CategoryNav currentCategory="blog" opaque={false} />
|
||||
|
||||
<!-- Section héro avec posts à la une -->
|
||||
{featuredPosts.length > 0 && (
|
||||
<section class="featured-section">
|
||||
<div class="featured-grid">
|
||||
{featuredPosts.map(post => (
|
||||
<article class="featured-post">
|
||||
<a href={`/photo/blog/${post.slug}`} class="post-link">
|
||||
<div class="post-image">
|
||||
{post.resolvedCoverImage && <Image src={post.resolvedCoverImage} alt={post.data.title} widths={[600, 900, 1200]} formats={['webp', 'avif']} />}
|
||||
<div class="post-overlay">
|
||||
<div class="post-content">
|
||||
<span class="post-badge">À la une</span>
|
||||
<h2 class="post-title">{post.data.title}</h2>
|
||||
<p class="post-description">{post.data.description}</p>
|
||||
<time class="post-date">
|
||||
{new Date(post.data.date).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Grille des autres posts -->
|
||||
<section class="posts-grid">
|
||||
<div class="grid-container">
|
||||
{regularPosts.map(post => (
|
||||
<article class="post-item">
|
||||
<a href={`/photo/blog/${post.slug}`} class="post-link">
|
||||
{post.resolvedCoverImage && <Image src={post.resolvedCoverImage} alt={post.data.title} widths={[400, 600, 800]} formats={['webp', 'avif']} />}
|
||||
<div class="post-overlay">
|
||||
<div class="overlay-content">
|
||||
<h3 class="post-title">{post.data.title}</h3>
|
||||
<p class="post-subtitle">{post.data.description}</p>
|
||||
<time class="post-date">
|
||||
{new Date(post.data.date).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PhotoLayout>
|
||||
|
||||
<style>
|
||||
.blog-container {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
padding-top: 53px; /* Hauteur du header fixe */
|
||||
}
|
||||
|
||||
/* Section à la une */
|
||||
.featured-section {
|
||||
padding: 40px 20px 60px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.featured-post {
|
||||
position: relative;
|
||||
height: 450px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.featured-post .post-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.featured-post img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.featured-post:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.featured-post .post-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.7) 100%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-post .post-content {
|
||||
color: white;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.featured-post .post-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.featured-post .post-description {
|
||||
font-size: 15px;
|
||||
margin: 0 0 10px 0;
|
||||
opacity: 0.9;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.featured-post .post-date {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Grille des posts */
|
||||
.posts-grid {
|
||||
padding: 0 20px 100px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: block;
|
||||
position: relative;
|
||||
aspect-ratio: 3/2;
|
||||
}
|
||||
|
||||
.post-link img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.post-item .post-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.post-item:hover .post-overlay {
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 100%);
|
||||
}
|
||||
|
||||
.post-item:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.post-item .overlay-content {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.post-item .post-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.post-item .post-subtitle {
|
||||
font-size: 14px;
|
||||
margin: 0 0 6px 0;
|
||||
opacity: 0.9;
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.post-item .post-date {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.grid-container {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.featured-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.featured-post {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.featured-post .post-overlay {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.featured-post .post-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.blog-container {
|
||||
padding-top: 53px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featured-section {
|
||||
padding: 20px 15px 40px;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
padding: 0 15px 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import projects from "../collections/projects.json";
|
||||
import projects from "../data/projects.json";
|
||||
import PageHeading from "../components/page-heading.astro";
|
||||
import Project from "../components/project.astro";
|
||||
import Layout from "../layouts/main.astro";
|
||||
|
|
|
|||
107
src/utils/i18n.ts
Normal file
107
src/utils/i18n.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export const defaultLocale = 'fr';
|
||||
export const locales = ['fr', 'en', 'ar'] as const;
|
||||
export type Locale = typeof locales[number];
|
||||
|
||||
export function getLocaleFromUrl(url: URL): Locale {
|
||||
const [, locale] = url.pathname.split('/');
|
||||
if (locales.includes(locale as Locale)) {
|
||||
return locale as Locale;
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function getLocalizedPath(path: string, locale: Locale): string {
|
||||
if (locale === defaultLocale) {
|
||||
return path;
|
||||
}
|
||||
return `/${locale}${path}`;
|
||||
}
|
||||
|
||||
export function removeLocaleFromPath(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (locales.includes(segments[0] as Locale)) {
|
||||
segments.shift();
|
||||
}
|
||||
return '/' + segments.join('/');
|
||||
}
|
||||
|
||||
export interface LocalizedContent {
|
||||
fr: string;
|
||||
en: string;
|
||||
ar: string;
|
||||
}
|
||||
|
||||
export const translations = {
|
||||
nav: {
|
||||
home: { fr: 'Accueil', en: 'Home', ar: 'الرئيسية' },
|
||||
pro: { fr: 'Pro', en: 'Pro', ar: 'مهني' },
|
||||
comedy: { fr: 'Comédie', en: 'Comedy', ar: 'كوميديا' },
|
||||
photo: { fr: 'Photo', en: 'Photo', ar: 'تصوير' },
|
||||
blog: { fr: 'Blog', en: 'Blog', ar: 'مدونة' },
|
||||
about: { fr: 'À propos', en: 'About', ar: 'حول' },
|
||||
projects: { fr: 'Projets', en: 'Projects', ar: 'مشاريع' },
|
||||
talks: { fr: 'Talks', en: 'Talks', ar: 'محاضرات' },
|
||||
gallery: { fr: 'Galerie', en: 'Gallery', ar: 'معرض' },
|
||||
shows: { fr: 'Spectacles', en: 'Shows', ar: 'عروض' },
|
||||
now: { fr: 'Maintenant', en: 'Now', ar: 'الآن' },
|
||||
uses: { fr: 'Utilise', en: 'Uses', ar: 'يستخدم' },
|
||||
},
|
||||
common: {
|
||||
readMore: { fr: 'Lire la suite', en: 'Read more', ar: 'اقرأ المزيد' },
|
||||
backToHome: { fr: 'Retour à l\'accueil', en: 'Back to home', ar: 'العودة إلى الرئيسية' },
|
||||
publishedOn: { fr: 'Publié le', en: 'Published on', ar: 'نشر في' },
|
||||
by: { fr: 'par', en: 'by', ar: 'بواسطة' },
|
||||
allPosts: { fr: 'Tous les articles', en: 'All posts', ar: 'جميع المقالات' },
|
||||
recentPosts: { fr: 'Articles récents', en: 'Recent posts', ar: 'المقالات الأخيرة' },
|
||||
categories: { fr: 'Catégories', en: 'Categories', ar: 'الفئات' },
|
||||
tags: { fr: 'Tags', en: 'Tags', ar: 'علامات' },
|
||||
search: { fr: 'Rechercher', en: 'Search', ar: 'بحث' },
|
||||
darkMode: { fr: 'Mode sombre', en: 'Dark mode', ar: 'الوضع الداكن' },
|
||||
lightMode: { fr: 'Mode clair', en: 'Light mode', ar: 'الوضع الفاتح' },
|
||||
},
|
||||
categories: {
|
||||
pro: { fr: 'Professionnel', en: 'Professional', ar: 'مهني' },
|
||||
comedy: { fr: 'Comédie', en: 'Comedy', ar: 'كوميديا' },
|
||||
photo: { fr: 'Photographie', en: 'Photography', ar: 'تصوير' },
|
||||
dev: { fr: 'Développement', en: 'Development', ar: 'تطوير' },
|
||||
},
|
||||
pages: {
|
||||
home: {
|
||||
title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
|
||||
subtitle: {
|
||||
fr: 'Développeur • Comédien • Photographe',
|
||||
en: 'Developer • Comedian • Photographer',
|
||||
ar: 'مطور • ممثل كوميدي • مصور'
|
||||
},
|
||||
description: {
|
||||
fr: 'Bienvenue dans mon univers créatif où le code rencontre l\'art',
|
||||
en: 'Welcome to my creative universe where code meets art',
|
||||
ar: 'مرحبًا بكم في عالمي الإبداعي حيث يلتقي الكود بالفن'
|
||||
}
|
||||
},
|
||||
pro: {
|
||||
title: { fr: 'Parcours Professionnel', en: 'Professional Journey', ar: 'المسار المهني' },
|
||||
description: {
|
||||
fr: 'Développeur passionné par le Software Craftsmanship',
|
||||
en: 'Developer passionate about Software Craftsmanship',
|
||||
ar: 'مطور شغوف بحرفية البرمجيات'
|
||||
}
|
||||
},
|
||||
comedy: {
|
||||
title: { fr: 'Univers Comédie', en: 'Comedy Universe', ar: 'عالم الكوميديا' },
|
||||
description: {
|
||||
fr: 'Acteur et créateur de contenus humoristiques',
|
||||
en: 'Actor and creator of humorous content',
|
||||
ar: 'ممثل ومنشئ محتوى فكاهي'
|
||||
}
|
||||
},
|
||||
photo: {
|
||||
title: { fr: 'Portfolio Photo', en: 'Photo Portfolio', ar: 'معرض الصور' },
|
||||
description: {
|
||||
fr: 'Capturer l\'instant, raconter une histoire',
|
||||
en: 'Capturing the moment, telling a story',
|
||||
ar: 'التقاط اللحظة، سرد قصة'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue