mirror of
https://github.com/ArthurDanjou/artsite.git
synced 2026-02-08 07:05:52 +01:00
Complete projects pages with listing and detail views
Co-authored-by: ArthurDanjou <29738535+ArthurDanjou@users.noreply.github.com>
This commit is contained in:
@@ -3,11 +3,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div />
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,178 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const { data: project } = await useAsyncData(`project-${slug}`, () => {
|
||||||
|
return queryCollection('projects').where('extension', '=', 'md').where('slug', '=', slug).first()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!project.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Project not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the structure for ContentRenderer
|
||||||
|
const projectWithBody = computed(() => {
|
||||||
|
if (!project.value) return null
|
||||||
|
return {
|
||||||
|
...project.value,
|
||||||
|
body: project.value.meta?.body
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: project.value.title,
|
||||||
|
description: project.value.description,
|
||||||
|
ogTitle: `${project.value.title} • Arthur Danjou`,
|
||||||
|
ogDescription: project.value.description,
|
||||||
|
twitterCard: 'summary_large_image'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
'active': 'green',
|
||||||
|
'completed': 'blue',
|
||||||
|
'archived': 'gray',
|
||||||
|
'in-progress': 'amber'
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
'Personal Project': 'purple',
|
||||||
|
'Academic Project': 'sky',
|
||||||
|
'Infrastructure Project': 'emerald',
|
||||||
|
'Internship Project': 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!project.value?.publishedAt) return null
|
||||||
|
return new Date(project.value.publishedAt).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<main
|
||||||
|
v-if="project"
|
||||||
</div>
|
class="space-y-8"
|
||||||
|
>
|
||||||
|
<!-- Back Button -->
|
||||||
|
<div>
|
||||||
|
<UButton
|
||||||
|
to="/projects"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-ph-arrow-left"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Back to Projects
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Header -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<UIcon
|
||||||
|
v-if="project.icon"
|
||||||
|
:name="project.icon"
|
||||||
|
class="text-5xl text-neutral-700 dark:text-neutral-300 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-neutral-900 dark:text-white mb-3">
|
||||||
|
{{ project.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
<UBadge
|
||||||
|
v-if="project.type"
|
||||||
|
:color="typeColors[project.type] || 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{{ project.type }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="project.status"
|
||||||
|
:color="statusColors[project.status] || 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{{ project.status }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="project.favorite"
|
||||||
|
color="amber"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
⭐ Favorite
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-lg text-neutral-600 dark:text-neutral-400">
|
||||||
|
{{ project.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mt-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<span
|
||||||
|
v-if="formattedDate"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<UIcon name="i-ph-calendar-duotone" />
|
||||||
|
{{ formattedDate }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="project.readingTime"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<UIcon name="i-ph-clock-duotone" />
|
||||||
|
{{ project.readingTime }} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div
|
||||||
|
v-if="project.tags && project.tags.length > 0"
|
||||||
|
class="flex flex-wrap gap-2 pt-2"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
v-for="tag in project.tags"
|
||||||
|
:key="tag"
|
||||||
|
color="gray"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<!-- Project Content -->
|
||||||
|
<ContentRenderer
|
||||||
|
v-if="projectWithBody"
|
||||||
|
:value="projectWithBody"
|
||||||
|
class="prose dark:prose-invert max-w-none
|
||||||
|
prose-headings:font-bold prose-headings:text-neutral-900 dark:prose-headings:text-white
|
||||||
|
prose-p:text-neutral-700 dark:prose-p:text-neutral-300
|
||||||
|
prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-strong:text-neutral-900 dark:prose-strong:text-white
|
||||||
|
prose-code:text-neutral-800 dark:prose-code:text-neutral-200
|
||||||
|
prose-pre:bg-neutral-100 dark:prose-pre:bg-neutral-800
|
||||||
|
prose-ul:text-neutral-700 dark:prose-ul:text-neutral-300
|
||||||
|
prose-ol:text-neutral-700 dark:prose-ol:text-neutral-300
|
||||||
|
prose-li:text-neutral-700 dark:prose-li:text-neutral-300
|
||||||
|
prose-blockquote:border-neutral-300 dark:prose-blockquote:border-neutral-700
|
||||||
|
prose-blockquote:text-neutral-700 dark:prose-blockquote:text-neutral-400
|
||||||
|
prose-img:rounded-lg prose-img:shadow-lg"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<p class="text-neutral-600 dark:text-neutral-400">
|
||||||
|
No content available
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</ContentRenderer>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const { data: projects } = await useAsyncData('projects', () => {
|
const { data: projects } = await useAsyncData('projects', () => {
|
||||||
return queryCollection('projects').order('publishedAt', 'DESC').all()
|
return queryCollection('projects').where('extension', '=', 'md').order('publishedAt', 'DESC').all()
|
||||||
})
|
})
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
@@ -10,8 +10,220 @@ useSeoMeta({
|
|||||||
ogDescription: 'Bridging the gap between theoretical models and production systems. Explore my experimental labs, open-source contributions, and engineering work.',
|
ogDescription: 'Bridging the gap between theoretical models and production systems. Explore my experimental labs, open-source contributions, and engineering work.',
|
||||||
twitterCard: 'summary_large_image'
|
twitterCard: 'summary_large_image'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedStatus = ref<string | null>(null)
|
||||||
|
const selectedTags = ref<string[]>([])
|
||||||
|
|
||||||
|
const statuses = computed(() => {
|
||||||
|
const allStatuses = new Set<string>()
|
||||||
|
projects.value?.forEach((project) => {
|
||||||
|
if (project.status) allStatuses.add(project.status)
|
||||||
|
})
|
||||||
|
return Array.from(allStatuses).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const allTags = computed(() => {
|
||||||
|
const tags = new Set<string>()
|
||||||
|
projects.value?.forEach((project) => {
|
||||||
|
project.tags?.forEach((tag: string) => tags.add(tag))
|
||||||
|
})
|
||||||
|
return Array.from(tags).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredProjects = computed(() => {
|
||||||
|
if (!projects.value) return []
|
||||||
|
|
||||||
|
return projects.value.filter((project) => {
|
||||||
|
const statusMatch = !selectedStatus.value || project.status === selectedStatus.value
|
||||||
|
const tagsMatch = selectedTags.value.length === 0
|
||||||
|
|| selectedTags.value.some(tag => project.tags?.includes(tag))
|
||||||
|
return statusMatch && tagsMatch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
'active': 'green',
|
||||||
|
'completed': 'blue',
|
||||||
|
'archived': 'gray',
|
||||||
|
'in-progress': 'amber'
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
'Personal Project': 'purple',
|
||||||
|
'Academic Project': 'sky',
|
||||||
|
'Infrastructure Project': 'emerald',
|
||||||
|
'Internship Project': 'orange'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(tag: string) {
|
||||||
|
const index = selectedTags.value.indexOf(tag)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedTags.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
selectedTags.value.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div />
|
<main class="space-y-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Engineering & Research Labs
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-neutral-600 dark:text-neutral-400">
|
||||||
|
Bridging the gap between theoretical models and production systems. Explore my experimental labs, open-source contributions, and engineering work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Status:</span>
|
||||||
|
<UButton
|
||||||
|
:variant="!selectedStatus ? 'solid' : 'ghost'"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
@click="selectedStatus = null"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status"
|
||||||
|
:variant="selectedStatus === status ? 'solid' : 'ghost'"
|
||||||
|
:color="statusColors[status] || 'neutral'"
|
||||||
|
size="sm"
|
||||||
|
@click="selectedStatus = selectedStatus === status ? null : status"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags Filter -->
|
||||||
|
<div
|
||||||
|
v-if="allTags.length > 0"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
:color="selectedTags.includes(tag) ? 'primary' : 'gray'"
|
||||||
|
:variant="selectedTags.includes(tag) ? 'solid' : 'outline'"
|
||||||
|
class="cursor-pointer hover:scale-105 transition-transform"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<UCard
|
||||||
|
v-for="project in filteredProjects"
|
||||||
|
:key="project.slug"
|
||||||
|
:to="`/projects/${project.slug}`"
|
||||||
|
class="hover:scale-[1.02] transition-transform cursor-pointer"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex items-start gap-3 flex-1">
|
||||||
|
<UIcon
|
||||||
|
v-if="project.icon"
|
||||||
|
:name="project.icon"
|
||||||
|
class="text-3xl text-neutral-700 dark:text-neutral-300 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white truncate">
|
||||||
|
{{ project.title }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<UBadge
|
||||||
|
v-if="project.type"
|
||||||
|
:color="typeColors[project.type] || 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ project.type }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="project.status"
|
||||||
|
:color="statusColors[project.status] || 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ project.status }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="project.favorite"
|
||||||
|
color="amber"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
⭐ Favorite
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-3">
|
||||||
|
{{ project.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<UBadge
|
||||||
|
v-for="tag in project.tags?.slice(0, 3)"
|
||||||
|
:key="tag"
|
||||||
|
color="gray"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="project.tags && project.tags.length > 3"
|
||||||
|
color="gray"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
+{{ project.tags.length - 3 }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="project.readingTime"
|
||||||
|
class="text-xs text-neutral-500 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
{{ project.readingTime }} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
v-if="filteredProjects.length === 0"
|
||||||
|
class="text-center py-12"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-ph-folder-open-duotone"
|
||||||
|
class="text-6xl text-neutral-400 dark:text-neutral-600 mb-4"
|
||||||
|
/>
|
||||||
|
<p class="text-lg text-neutral-600 dark:text-neutral-400">
|
||||||
|
No projects found with the selected filters.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
class="mt-4"
|
||||||
|
@click="selectedStatus = null; selectedTags = []"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -9532,14 +9532,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "13.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/comment-parser": {
|
"node_modules/comment-parser": {
|
||||||
@@ -20035,15 +20033,6 @@
|
|||||||
"url": "https://opencollective.com/svgo"
|
"url": "https://opencollective.com/svgo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svgo/node_modules/commander": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/system-architecture": {
|
"node_modules/system-architecture": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
||||||
|
|||||||
4
worker-configuration.d.ts
vendored
4
worker-configuration.d.ts
vendored
@@ -8605,7 +8605,7 @@ type AIGatewayHeaders = {
|
|||||||
[key: string]: string | number | boolean | object;
|
[key: string]: string | number | boolean | object;
|
||||||
};
|
};
|
||||||
type AIGatewayUniversalRequest = {
|
type AIGatewayUniversalRequest = {
|
||||||
provider: AIGatewayProviders | string; // eslint-disable-line
|
provider: AIGatewayProviders | string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
headers: Partial<AIGatewayHeaders>;
|
headers: Partial<AIGatewayHeaders>;
|
||||||
query: unknown;
|
query: unknown;
|
||||||
@@ -8621,7 +8621,7 @@ declare abstract class AiGateway {
|
|||||||
gateway?: UniversalGatewayOptions;
|
gateway?: UniversalGatewayOptions;
|
||||||
extraHeaders?: object;
|
extraHeaders?: object;
|
||||||
}): Promise<Response>;
|
}): Promise<Response>;
|
||||||
getUrl(provider?: AIGatewayProviders | string): Promise<string>; // eslint-disable-line
|
getUrl(provider?: AIGatewayProviders | string): Promise<string>;
|
||||||
}
|
}
|
||||||
interface AutoRAGInternalError extends Error {
|
interface AutoRAGInternalError extends Error {
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user