Skip to content

Commit 1d56300

Browse files
add gallery
1 parent c220e7b commit 1d56300

File tree

8 files changed

+471
-0
lines changed

8 files changed

+471
-0
lines changed

app/pages/admin/gallery/index.vue

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<template>
2+
<div class="container mx-auto px-4 py-8 max-w-5xl">
3+
<h1 class="text-2xl font-bold text-accent-primary mb-6">Gallery Admin</h1>
4+
5+
<div class="grid grid-cols-1 gap-8">
6+
<div class="bg-fill-secondary border border-separator-primary rounded-xl p-4">
7+
<h2 class="text-lg font-semibold text-accent-primary mb-4">Upload new image</h2>
8+
<form class="flex flex-col gap-3" @submit.prevent="uploadImage" enctype="multipart/form-data">
9+
<input ref="fileInput" type="file" accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
10+
@change="handleFileSelect"
11+
class="w-full p-2 rounded bg-transparent border border-separator-primary text-label-primary"
12+
required />
13+
<div v-if="previewUrl" class="w-full">
14+
<img :src="previewUrl" alt="Preview"
15+
class="max-w-full max-h-64 rounded border border-separator-primary" />
16+
</div>
17+
<input v-model="altText" type="text" placeholder="Alt text (optional)"
18+
class="w-full p-2 rounded bg-transparent border border-separator-primary text-label-primary" />
19+
<MainButton button-style="primary" size="M" :label="uploading ? 'Uploading…' : 'Upload'" />
20+
</form>
21+
<p v-if="uploadError" class="text-red-500 mt-2">{{ uploadError }}</p>
22+
<p v-if="uploadOk" class="text-green-600 mt-2">Uploaded successfully</p>
23+
</div>
24+
</div>
25+
26+
<div class="mt-10 bg-fill-secondary border border-separator-primary rounded-xl p-4">
27+
<div class="flex items-center justify-between mb-3">
28+
<h2 class="text-lg font-semibold text-accent-primary">Gallery Images</h2>
29+
</div>
30+
<div v-if="listPending">Loading…</div>
31+
<div v-else-if="items.length === 0" class="text-label-secondary text-center py-8">
32+
No gallery images yet. Upload one above to get started.
33+
</div>
34+
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
35+
<div v-for="item in items" :key="item.id" class="relative group">
36+
<div class="aspect-square rounded overflow-hidden bg-fill-tertiary border border-separator-primary">
37+
<img :src="`/api/gallery/image/${item.imageFilename}`" :alt="item.altText || 'Gallery image'"
38+
class="w-full h-full object-cover" />
39+
</div>
40+
<div class="mt-2 text-xs text-label-secondary truncate" v-if="item.altText">
41+
{{ item.altText }}
42+
</div>
43+
<div class="mt-1 text-xs text-label-secondary">
44+
{{ new Date(item.createdAt).toLocaleDateString() }}
45+
</div>
46+
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
47+
<MainButton button-style="ghost" size="S" label="Delete" @click="deleteImage(item.id)" />
48+
</div>
49+
</div>
50+
</div>
51+
</div>
52+
</div>
53+
</template>
54+
55+
<script setup lang="ts">
56+
import { definePageMeta } from '#imports'
57+
import { ref } from 'vue'
58+
59+
definePageMeta({ layout: 'default', middleware: ['auth'] })
60+
61+
const fileInput = ref<HTMLInputElement | null>(null)
62+
const selectedFile = ref<File | null>(null)
63+
const previewUrl = ref<string | null>(null)
64+
const altText = ref('')
65+
const uploading = ref(false)
66+
const uploadError = ref('')
67+
const uploadOk = ref(false)
68+
69+
function handleFileSelect(event: Event) {
70+
const target = event.target as HTMLInputElement
71+
const file = target.files?.[0]
72+
if (file) {
73+
selectedFile.value = file
74+
// Create preview
75+
const reader = new FileReader()
76+
reader.onload = (e) => {
77+
previewUrl.value = e.target?.result as string
78+
}
79+
reader.readAsDataURL(file)
80+
}
81+
}
82+
83+
async function uploadImage() {
84+
if (!selectedFile.value) {
85+
uploadError.value = 'Please select an image file'
86+
return
87+
}
88+
89+
uploading.value = true
90+
uploadError.value = ''
91+
uploadOk.value = false
92+
93+
try {
94+
const formData = new FormData()
95+
formData.append('image', selectedFile.value)
96+
if (altText.value.trim()) {
97+
formData.append('altText', altText.value.trim())
98+
}
99+
100+
const response = await fetch('/api/gallery', {
101+
method: 'POST',
102+
body: formData,
103+
credentials: 'include'
104+
})
105+
106+
if (!response.ok) {
107+
const error = await response.json()
108+
throw new Error(error.statusMessage || 'Failed to upload image')
109+
}
110+
111+
uploadOk.value = true
112+
selectedFile.value = null
113+
previewUrl.value = null
114+
altText.value = ''
115+
if (fileInput.value) {
116+
fileInput.value.value = ''
117+
}
118+
await refreshList()
119+
setTimeout(() => {
120+
uploadOk.value = false
121+
}, 3000)
122+
} catch (e: any) {
123+
uploadError.value = e?.message || 'Failed to upload image'
124+
} finally {
125+
uploading.value = false
126+
}
127+
}
128+
129+
const items = ref<
130+
Array<{
131+
id: string
132+
imageFilename: string
133+
altText: string
134+
createdAt: string
135+
}>
136+
>([])
137+
const listPending = ref(false)
138+
const deleting = ref<string | null>(null)
139+
140+
async function refreshList() {
141+
listPending.value = true
142+
try {
143+
const data = await $fetch('/api/gallery')
144+
items.value = data || []
145+
} catch (error) {
146+
console.error('Failed to refresh gallery list:', error)
147+
} finally {
148+
listPending.value = false
149+
}
150+
}
151+
152+
async function deleteImage(id: string) {
153+
if (!confirm('Are you sure you want to delete this image?')) {
154+
return
155+
}
156+
157+
deleting.value = id
158+
try {
159+
const response = await fetch(`/api/gallery/${id}`, {
160+
method: 'DELETE',
161+
credentials: 'include'
162+
})
163+
164+
if (!response.ok) {
165+
const error = await response.json()
166+
throw new Error(error.statusMessage || 'Failed to delete image')
167+
}
168+
169+
await refreshList()
170+
} catch (e: any) {
171+
alert(e?.message || e?.statusMessage || 'Failed to delete image')
172+
} finally {
173+
deleting.value = null
174+
}
175+
}
176+
177+
await refreshList()
178+
</script>

app/pages/home/index.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ const cards = [
6767
title: 'Медіа про нас',
6868
description: 'Керування медіа постами для головної сторінки',
6969
},
70+
{
71+
route: '/admin/gallery',
72+
icon: 'mdi:image-multiple',
73+
title: 'Галерея',
74+
description: 'Керування зображеннями галереї',
75+
},
7076
]
7177
7278
onMounted(() => {

app/pages/index.vue

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,25 @@
120120
</div>
121121
</div>
122122

123+
<div v-if="galleryImages && galleryImages.length > 0" class="mb-20">
124+
<h2 class="text-3xl font-bold mb-8 text-center text-accent-primary">
125+
Галерея
126+
</h2>
127+
<div class="overflow-x-auto">
128+
<div class="flex gap-4 pb-4" style="scroll-snap-type: x mandatory; scroll-behavior: smooth;">
129+
<div v-for="image in galleryImages" :key="image.id" class="flex-shrink-0"
130+
style="scroll-snap-align: start;">
131+
<div
132+
class="w-64 h-64 md:w-80 md:h-80 rounded-xl overflow-hidden bg-fill-tertiary border border-separator-primary">
133+
<img :src="`/api/gallery/image/${image.imageFilename}`"
134+
:alt="image.altText || 'Gallery image'" class="w-full h-full object-cover"
135+
loading="lazy" />
136+
</div>
137+
</div>
138+
</div>
139+
</div>
140+
</div>
141+
123142
<div class="border-t border-separator-primary pt-16">
124143
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
125144
<div>
@@ -184,6 +203,13 @@ const { data: mediaPosts } = await useFetch<Array<{
184203
createdAt: string
185204
}>>('/api/media')
186205
206+
const { data: galleryImages } = await useFetch<Array<{
207+
id: string
208+
imageFilename: string
209+
altText: string
210+
createdAt: string
211+
}>>('/api/gallery')
212+
187213
onMounted(async () => {
188214
trackEvent('page_view', { page: 'landing' })
189215

server/api/gallery/[id].delete.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createError, defineEventHandler, getRouterParam, useNitroApp } from '#imports'
2+
import { ObjectId } from 'mongodb'
3+
import { FileStoreType } from '~~/server/plugins/3_file_store'
4+
5+
export default defineEventHandler(async (event) => {
6+
const user = event.context.user
7+
if (!user) {
8+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
9+
}
10+
11+
const id = getRouterParam(event, 'id') as string
12+
13+
if (!id) {
14+
throw createError({ statusCode: 400, statusMessage: 'ID is required' })
15+
}
16+
17+
const db = useNitroApp().db
18+
const logger = useNitroApp().logger
19+
20+
// Validate ObjectId format
21+
if (!ObjectId.isValid(id)) {
22+
throw createError({ statusCode: 400, statusMessage: 'Invalid ID format' })
23+
}
24+
25+
try {
26+
// Find the image
27+
const image = await db.collection('galleryImages').findOne({ _id: new ObjectId(id) })
28+
29+
if (!image) {
30+
throw createError({ statusCode: 404, statusMessage: 'Gallery image not found' })
31+
}
32+
33+
// Delete image from GridFS if it exists
34+
if (image.imageFilename) {
35+
try {
36+
const fileStore = useNitroApp().fileStores.getFileStore(FileStoreType.LandingGalleryImage)
37+
await fileStore.deleteFileByName(image.imageFilename)
38+
logger.info(`Deleted image file: ${image.imageFilename}`)
39+
} catch (error) {
40+
logger.warn(`Failed to delete image file: ${image.imageFilename}`, error)
41+
// Continue with image deletion even if file deletion fails
42+
}
43+
}
44+
45+
// Delete image from MongoDB
46+
await db.collection('galleryImages').deleteOne({ _id: new ObjectId(id) })
47+
48+
logger.info(`Gallery image deleted: ${id}`)
49+
50+
return { ok: true }
51+
} catch (error: any) {
52+
logger.error('Failed to delete gallery image:', error)
53+
if (error.statusCode) {
54+
throw error
55+
}
56+
throw createError({
57+
statusCode: 500,
58+
statusMessage: error.message || 'Failed to delete gallery image'
59+
})
60+
}
61+
})
62+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createError, defineEventHandler, getRouterParam, useNitroApp } from '#imports'
2+
import { FileStoreType } from '~~/server/plugins/3_file_store'
3+
4+
export default defineEventHandler(async (event) => {
5+
const filename = getRouterParam(event, 'filename') as string
6+
7+
if (!filename) {
8+
throw createError({
9+
statusCode: 400,
10+
statusMessage: 'Filename is required',
11+
})
12+
}
13+
14+
try {
15+
const fileStore = useNitroApp().fileStores.getFileStore(FileStoreType.LandingGalleryImage)
16+
const imageFile = await fileStore.openDownloadStreamByName(filename)
17+
18+
// Determine content type from filename
19+
let contentType = 'image/jpeg'
20+
if (filename.endsWith('.png')) {
21+
contentType = 'image/png'
22+
} else if (filename.endsWith('.gif')) {
23+
contentType = 'image/gif'
24+
} else if (filename.endsWith('.webp')) {
25+
contentType = 'image/webp'
26+
}
27+
28+
event.node.res.setHeader('Content-Type', contentType)
29+
event.node.res.setHeader('Cache-Control', 'public, max-age=86400')
30+
return imageFile
31+
} catch (error: any) {
32+
useNitroApp().logger.error('Failed to serve gallery image:', error)
33+
throw createError({
34+
statusCode: 404,
35+
statusMessage: 'Image not found',
36+
})
37+
}
38+
})
39+

server/api/gallery/index.get.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineEventHandler, useNitroApp } from '#imports'
2+
3+
export default defineEventHandler(async (event) => {
4+
const db = useNitroApp().db
5+
6+
const images = await db
7+
.collection('galleryImages')
8+
.find({})
9+
.sort({ order: 1, createdAt: -1 })
10+
.toArray()
11+
12+
// Convert _id to string id
13+
const imagesWithId = images.map(image => ({
14+
id: image._id.toString(),
15+
imageFilename: image.imageFilename,
16+
altText: image.altText || '',
17+
createdAt: image.createdAt
18+
}))
19+
20+
return imagesWithId
21+
})
22+

0 commit comments

Comments
 (0)