Dunlin is a lightweight, self-hosted CDN for personal projects.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 327 lines 11 kB view raw
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>