Dunlin is a lightweight, self-hosted CDN for personal projects.
1<script setup lang="ts">
2import Logo from '@/components/ui/Logo.vue'
3import { useAuthUser } from '@/router/auth/AuthUserProvider'
4import { Label } from '@/components/ui/label'
5import Button from '@/components/ui/Button.vue'
6import {
7 AlertCircle,
8 Archive,
9 ChartArea,
10 ChevronDown,
11 CornerDownRight,
12 File,
13 Folder,
14 Home,
15 PanelLeft,
16 Search,
17 Upload,
18} from 'lucide-vue-next'
19import { Input } from '@/components/ui/input'
20import {
21 TableHeader,
22 Table,
23 TableRow,
24 TableHead,
25 TableBody,
26 TableCell,
27} from '@/components/ui/table'
28import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
29import DashboardLayout from '@/components/DashboardLayout.vue'
30import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
31import type { TeamProjectResponse, TeamResponse } from '@/lib/types'
32import { useRoute, useRouter } from 'vue-router'
33import { computed, Fragment, onMounted, ref, watch } from 'vue'
34import { watchDeep } from '@vueuse/core'
35import normalize from 'path-normalize'
36import TeamsDropdown from '@/components/header/TeamsDropdown.vue'
37import TeamProjectsDropdown from '@/components/header/TeamProjectsDropdown.vue'
38import {
39 Dialog,
40 DialogContent,
41 DialogFooter,
42 DialogHeader,
43 DialogTitle,
44 DialogTrigger,
45} from '@/components/ui/dialog'
46import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
47import FileUploader from '@/components/FileUploader.vue'
48import { humanFileSize } from '@/lib/bytes'
49import Breadcrumbs from '@/components/Breadcrumbs.vue'
50
51const { authUser } = useAuthUser()
52const route = useRoute()
53const router = useRouter()
54const teamSlug = computed(() => route.params.team as string)
55const projectSlug = computed(() => route.params.project as string)
56const apiUrl = import.meta.env.VITE_API_URL
57
58type File = {
59 type: 'dir' | 'file'
60 name: string
61 lastModified: string
62 size: number
63}
64
65type FilesResponse = {
66 message: string
67 files: File[]
68}
69
70const { data: team } = useQuery<TeamResponse>({
71 queryKey: ['team', teamSlug],
72 queryFn: async () => {
73 const response = await fetch(`${import.meta.env.VITE_API_URL}/api/v1/teams/${teamSlug.value}`, {
74 credentials: 'include',
75 })
76 if (!response.ok) {
77 router.push('/auth/login')
78 throw new Error((await response.json()).message)
79 }
80 return response.json() as Promise<TeamResponse>
81 },
82})
83
84const { data: teamProject } = useQuery<TeamProjectResponse>({
85 queryKey: ['teamProject', teamSlug, projectSlug],
86 queryFn: async () => {
87 const response = await fetch(
88 `${import.meta.env.VITE_API_URL}/api/v1/teams/${teamSlug.value}/projects/${projectSlug.value}`,
89 {
90 credentials: 'include',
91 },
92 )
93 if (!response.ok) {
94 router.push('/auth/login')
95 throw new Error((await response.json()).message)
96 }
97 return response.json() as Promise<TeamProjectResponse>
98 },
99})
100
101const createFolderOpen = ref(false)
102const folderPath = ref('')
103
104const createFolder = useMutation({
105 mutationKey: ['createProject'],
106 mutationFn: async (path: string) => {
107 const response = await fetch(
108 `${import.meta.env.VITE_API_URL}/api/v1/teams/${teamSlug.value}/projects/${projectSlug.value}/folders`,
109 {
110 method: 'POST',
111 credentials: 'include',
112 body: JSON.stringify({
113 path: `${filepathWithSlashes.value}/${path.startsWith('/') ? path.substring(1) : path}`,
114 }),
115 },
116 )
117 if (!response.ok) {
118 throw new Error((await response.json()).message)
119 }
120 return response.json()
121 },
122 onSuccess() {
123 queryClient.invalidateQueries({ queryKey: ['files'] })
124 createFolderOpen.value = false
125 folderPath.value = ''
126 },
127})
128
129const error = ref('')
130const queryClient = useQueryClient()
131
132const rawFilepath = computed(() =>
133 normalize(Array.isArray(route.params.filepath) ? route.params.filepath.join('/') : ''),
134)
135
136const filepathWithSlashes = computed(() => (rawFilepath.value ? `/${rawFilepath.value}/` : '/'))
137
138const { data: files } = useQuery<FilesResponse>({
139 queryKey: ['files', teamSlug, projectSlug, filepathWithSlashes],
140 queryFn: async () => {
141 const url = `${import.meta.env.VITE_API_URL}/api/v1/teams/${teamSlug.value}/projects/${projectSlug.value}/files/${filepathWithSlashes.value}`
142 const response = await fetch(url, {
143 credentials: 'include',
144 })
145 if (!response.ok) {
146 error.value = (await response.json()).error
147 throw new Error((await response.json()).error)
148 }
149 return response.json() as Promise<FilesResponse>
150 },
151})
152
153watch([team, teamProject], () => {
154 if (!team.value && !teamProject.value) {
155 document.title = 'Index of'
156 return
157 }
158 document.title = `Index of /${team.value?.team.slug}/${teamProject.value?.teamProject.slug}${filepathWithSlashes.value}`
159})
160</script>
161
162<template>
163 <DashboardLayout>
164 <header class="h-[72px] py-4 px-6 flex justify-between items-center">
165 <div class="flex gap-4 font-medium items-center">
166 <Logo />
167
168 <router-link :to="`/-`" v-if="authUser.value">
169 <div class="text-neutral-400">/</div>
170 </router-link>
171 <div class="text-neutral-400" v-if="!authUser.value">/</div>
172
173 <TeamsDropdown v-if="authUser.value">
174 <div class="flex gap-2 items-center cursor-pointer text-neutral-400">
175 {{ team && team.team.name }}
176 <ChevronDown class="size-4 stroke-neutral-400" />
177 </div>
178 </TeamsDropdown>
179 <div v-if="!authUser.value" class="flex gap-2 items-center text-neutral-400">
180 {{ team && team.team.name }}
181 </div>
182
183 <router-link :to="`/-/${team && team.team.slug}`" v-if="authUser.value">
184 <div class="text-neutral-400">/</div>
185 </router-link>
186 <div class="text-neutral-400" v-if="!authUser.value">/</div>
187
188 <TeamProjectsDropdown v-if="authUser.value">
189 <div class="flex gap-2 items-center cursor-pointer">
190 {{ teamProject && teamProject.teamProject.name }}
191 <ChevronDown class="size-4 stroke-neutral-600" />
192 </div>
193 </TeamProjectsDropdown>
194 <div v-if="!authUser.value" class="flex gap-2 items-center">
195 {{ teamProject && teamProject.teamProject.name }}
196 </div>
197 <Breadcrumbs
198 :team-slug="route.params.team as string"
199 :project-slug="route.params.project as string"
200 :filepath="filepathWithSlashes"
201 />
202 </div>
203 <router-link to="/auth/login" v-if="!authUser.value">
204 <Button> Log in </Button>
205 </router-link>
206 <div class="flex items-center gap-4" v-if="authUser.value">
207 <Dialog>
208 <DialogTrigger>
209 <div class="relative w-[350px] h-8">
210 <Search class="size-4 stroke-neutral-400 absolute top-1/2 -translate-y-1/2 left-2" />
211 <Input class="px-8" placeholder="Search" />
212 </div>
213 </DialogTrigger>
214 <DialogContent
215 :show-close="false"
216 class="p-0 gap-0 divide-y divide-y-neutral-200 border-0"
217 >
218 <div class="relative h-12">
219 <Search class="size-4 stroke-neutral-400 absolute top-1/2 -translate-y-1/2 left-2" />
220 <Input class="px-8 h-12 rounded-b-none" placeholder="Search" />
221 </div>
222 <div class="h-[350px] flex flex-col gap-1 overflow-y-scroll p-2 no-scrollbar">
223 <Button class="w-full" size="sm" variant="secondary"> Test </Button>
224 </div>
225 </DialogContent>
226 </Dialog>
227 <FileUploader
228 :team-slug="route.params.team as string"
229 :project-slug="route.params.project as string"
230 :target-path="filepathWithSlashes"
231 />
232 <Dialog v-model:open="createFolderOpen">
233 <DialogTrigger :as-child="true">
234 <Button size="sm"><Folder class="size-4" /> New Folder </Button>
235 </DialogTrigger>
236 <DialogContent :show-close="true">
237 <DialogHeader>
238 <DialogTitle> Create a folder </DialogTitle>
239 </DialogHeader>
240 <form
241 @submit.prevent="() => createFolder.mutate(folderPath)"
242 class="flex flex-col gap-4"
243 >
244 <Input v-model="folderPath" placeholder="Name" />
245 <DialogFooter>
246 <Button> Create </Button>
247 </DialogFooter>
248 </form>
249 </DialogContent>
250 </Dialog>
251 <router-link :to="`/statistics`">
252 <Button size="sm"
253 ><ChartArea class="size-4" />
254 <div class="sr-only">To Statistics</div>
255 </Button>
256 </router-link>
257 </div>
258 </header>
259 <div class="p-4">
260 <Alert v-if="error" variant="destructive">
261 <AlertCircle class="w-4 h-4" />
262 <AlertTitle>Error</AlertTitle>
263 <AlertDescription> {{ error }} </AlertDescription>
264 </Alert>
265 <Table class="rounded-t-lg overflow-clip" v-if="!error">
266 <TableHeader>
267 <TableRow>
268 <TableHead> Name </TableHead>
269 <TableHead> Last Changed </TableHead>
270 <TableHead> Size </TableHead>
271 </TableRow>
272 </TableHeader>
273 <TableBody v-if="files">
274 <TableRow
275 class="hover:underline cursor-pointer"
276 v-for="file in [
277 rawFilepath !== '.'
278 ? {
279 type: 'dir',
280 name: '..',
281 lastModified: '',
282 size: '',
283 }
284 : null,
285 ...files.files.slice().sort((a, b) => {
286 if (a.type === 'dir' && b.type !== 'dir') return -1
287 if (a.type !== 'dir' && b.type === 'dir') return 1
288 return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
289 }),
290 ].filter((a) => !!a)"
291 v-bind:key="`${filepathWithSlashes}${file.name}`"
292 @click="
293 () => {
294 if (file.type === 'dir') {
295 queryClient.invalidateQueries({ queryKey: ['files'] })
296 router.replace(
297 `/-/${route.params.team}/${route.params.project}${filepathWithSlashes}${file.name}`,
298 )
299 } else {
300 // Window is added but the types don't seem to work see /frontend/env.d.ts
301 // @ts-ignore
302 window.location.href = `${apiUrl}/files/${route.params.team}/${route.params.project}${filepathWithSlashes}${file.name}`
303 }
304 }
305 "
306 >
307 <TableCell>
308 <div class="flex gap-2 items-center">
309 <File class="size-4 stroke-neutral-600" v-if="file.type === 'file'" />
310 <Folder class="size-4 stroke-neutral-600" v-if="file.type === 'dir'" />
311 {{ file.name }}
312 </div>
313 </TableCell>
314 <TableCell>{{
315 file.lastModified !== '' ? new Date(file.lastModified).toDateString() : ''
316 }}</TableCell>
317 <TableCell
318 ><div v-if="file.type === 'file'">
319 {{ humanFileSize(file.size as number) }}
320 </div></TableCell
321 >
322 </TableRow>
323 </TableBody>
324 </Table>
325 </div>
326 </DashboardLayout>
327</template>