Skip to content

Commit

Permalink
feat: file upload portal (#668)
Browse files Browse the repository at this point in the history
* chore(script): .env file updating script

* chore(sync): i am back home

* chore(sync): switching container

* chore(sync): i am back home

* chore(sync): today's work so far

* chore(sync): adopted fix by twc

* feat: file upload mostly finished (TODO: http error handling)

* feat: using new sidebar

* fix: moved bucket into .env

* feat: changeset
  • Loading branch information
q1zhen authored Dec 31, 2024
1 parent 231f03e commit 7521890
Show file tree
Hide file tree
Showing 18 changed files with 3,037 additions and 385 deletions.
5 changes: 5 additions & 0 deletions .changeset/plenty-sheep-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"enspire": minor
---

Added club file uploading portal
189 changes: 189 additions & 0 deletions app/components/custom/club-file-upload.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script setup lang="ts">
import type { FileCollection } from '@prisma/client'
import type { AllClubs } from '~~/types/api/user/all_clubs'
import Toaster from '@/components/ui/toast/Toaster.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import { toTypedSchema } from '@vee-validate/zod'
import dayjs from 'dayjs'
import { v4 as uuidv4 } from 'uuid'
import { useForm } from 'vee-validate'
import * as z from 'zod'
const props = defineProps({
club: {
type: String,
required: true,
},
collection: {
type: String,
required: true,
},
filetypes: {
type: Array<string>,
required: true,
},
title: {
type: String,
required: true,
},
})
definePageMeta({
middleware: ['auth'],
})
function fileTypesPrompt(fileTypes: string[]) {
if (fileTypes.length === 0 || fileTypes.includes('*')) {
return '无文件类型限制'
}
else {
return `上传类型为 ${fileTypes.join(', ').toUpperCase()} 的文件`
}
}
function fileTypesAcceptAttr(fileTypes: string[]) {
if (fileTypes.length === 0 || fileTypes.includes('*')) {
return '*'
}
else {
return fileTypes.map(type => `.${type}`).join(',')
}
}
// Still seems to be buggy
// const formSchema = toTypedSchema(z.object({
// file: z.custom(v => v, 'File missing'),
// }))
// 滚一边去
function readFileAsDataURL(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
const form = useForm({})
const inputKey = ref(uuidv4())
const submitting = ref(false)
const onSubmit = form.handleSubmit(async (values) => {
submitting.value = true
await $fetch('/api/files/newRecord', {
method: 'POST',
body: {
clubId: Number.parseInt(props.club),
collectionId: props.collection,
fileContent: await readFileAsDataURL(values.file),
rawName: values.file.name,
},
})
form.resetForm()
inputKey.value = uuidv4()
await updateClub()
submitting.value = false
})
const msg = ref('')
const currentClubData = ref(null)
const clubUpdating = ref(false)
async function updateClub() {
if (!props.club) {
msg.value = '请先选择一个社团'
currentClubData.value = undefined
return
}
clubUpdating.value = true
const data = await $fetch('/api/files/clubRecords', {
method: 'POST',
body: {
cludId: Number.parseInt(props.club),
collection: props.collection,
},
})
if (data && data.length !== 0) {
msg.value = `最后提交于 ${dayjs(data[0].createdAt).fromNow()}`
currentClubData.value = data[0]
}
else {
msg.value = '尚未提交'
currentClubData.value = undefined
}
clubUpdating.value = false
}
const downloadLink = ref('')
const downloadFilename = ref('')
const dlink: Ref<HTMLElement | null> = ref(null)
const downloading = ref(false)
async function download() {
if (currentClubData.value) {
downloading.value = true
const data = await $fetch('/api/files/download', {
method: 'POST',
body: {
fileId: currentClubData.value.fileId,
},
})
// const blob = new Blob([new Uint8Array(Array.from(atob(data), c => c.charCodeAt(0)))])
// window.open(URL.createObjectURL(blob))
// window.open(data)
downloadLink.value = data.url
downloadFilename.value = data.name
dlink.value.click()
downloading.value = false
}
downloadLink.value = ''
downloadFilename.value = ''
}
watch(
() => props.club,
async () => {
await updateClub()
},
)
await updateClub()
</script>

<template>
<Card class="px-4 py-4">
<div class="mb-5 text-xl font-bold">
{{ title }}
</div>
<form class="inline-block" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="file">
<FormItem>
<FormControl>
<Input
v-bind="componentField"
:key="inputKey" class="text-foreground"
type="file"
:accept="fileTypesAcceptAttr(filetypes)"
/>
</FormControl>
<FormDescription>
{{ fileTypesPrompt(filetypes) }}
</FormDescription>
<!-- <FormMessage /> -->
</FormItem>
</FormField>
<div class="mt-2">
<Button type="submit" variant="secondary" :disabled="!form.values.file || submitting || clubUpdating">
上传
</Button>
<Button v-if="currentClubData" :disabled="downloading" variant="outline" class="ml-2" type="button" @click="download">
下载
</Button>
</div>
</form>
<div v-if="submitting || clubUpdating" class="mt-2">
<Skeleton class="h-5 w-full" />
</div>
<div v-else class="mt-2">
{{ msg }}
</div>
<a ref="dlink" :href="downloadLink" :download="downloadFilename" class="hidden">Download</a>
</Card>
</template>
4 changes: 4 additions & 0 deletions app/components/custom/sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const sidebarData = ref({
},
...(isPresidentOrVicePresident.value
? [
{
title: '社团文件',
url: '/forms/files',
},
{
title: '活动记录',
url: '#',
Expand Down
10 changes: 8 additions & 2 deletions app/components/ui/input/Input.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core'
const props = defineProps<{
defaultValue?: string | number
Expand All @@ -20,5 +20,11 @@ const modelValue = useVModel(props, 'modelValue', emits, {
</script>

<template>
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class ?? '')">
<input v-model="modelValue" :class="cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>

<style scoped>
input[type="file"]::file-selector-button {
color: hsl(var(--foreground));
}
</style>
2 changes: 1 addition & 1 deletion app/components/ui/input/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default as Input } from '@/components/ui/input/Input.vue'
export { default as Input } from './Input.vue'
89 changes: 89 additions & 0 deletions app/pages/forms/files.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { FileCollection } from '@prisma/client'
import type { AllClubs } from '~~/types/api/user/all_clubs'
import ClubFileUpload from '@/components/custom/club-file-upload.vue'
import Toaster from '@/components/ui/toast/Toaster.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import { toTypedSchema } from '@vee-validate/zod'
import { v4 as uuidv4 } from 'uuid'
import { useForm } from 'vee-validate'
import * as z from 'zod'
// ZOD!
const formSchema = toTypedSchema(z.object({
file: z
.instanceof(FileList)
.refine(file => file?.length === 1, 'File is required.'),
}))
definePageMeta({
middleware: ['auth'],
})
useHead({
title: 'Club Files | Enspire',
})
const { toast } = useToast()
const { data: collectionsData, suspense: _s1 } = useQuery<FileCollection[]>({
queryKey: ['/api/files/collections'],
})
await _s1() // suspense要await
const collectionLoaded = ref(false)
if (collectionsData.value) {
collectionLoaded.value = true
}
else {
toast({
title: '错误',
description: '获取上传通道信息出错',
})
}
const { data: clubData, suspense: _s2 } = useQuery<AllClubs>({
queryKey: ['/api/user/all_clubs'],
})
await _s2() // suspense要await
const clubLoaded = ref(false)
if (clubData.value) {
clubLoaded.value = true
}
else {
toast({
title: '错误',
description: '获取社团信息出错',
})
}
const selectedClub = ref('')
</script>

<template>
<Select v-model="selectedClub">
<SelectTrigger class="mb-4 w-full lg:w-72">
<SelectValue placeholder="选择一个社团" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="club in clubData.president" :key="club.id" :value="club.id">
{{ club.name.zh }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="!collectionLoaded">
loading
</div>
<div v-if="collectionLoaded" class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ClubFileUpload
v-for="collection in collectionsData"
:key="collection.id"
:club="selectedClub"
:collection="collection.id"
:filetypes="collection.fileTypes"
:title="collection.name"
/>
</div>
<Toaster />
</template>
52 changes: 52 additions & 0 deletions db/migrations/20241226133450_file_upload/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- CreateEnum
CREATE TYPE "FormStatus" AS ENUM ('OPEN', 'CLOSED');

-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "FileUploadRecord" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"clubId" INTEGER NOT NULL,
"fileId" TEXT NOT NULL,
"fileUploadId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "FileUploadRecord_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "FileUpload" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"status" "FormStatus" NOT NULL,
"fileNaming" TEXT NOT NULL,
"fileTypes" TEXT[],

CONSTRAINT "FileUpload_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_clubId_key" ON "FileUploadRecord"("clubId");

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_fileId_key" ON "FileUploadRecord"("fileId");

-- CreateIndex
CREATE UNIQUE INDEX "FileUploadRecord_fileUploadId_key" ON "FileUploadRecord"("fileUploadId");

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "FileUploadRecord" ADD CONSTRAINT "FileUploadRecord_fileUploadId_fkey" FOREIGN KEY ("fileUploadId") REFERENCES "FileUpload"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
5 changes: 5 additions & 0 deletions db/migrations/20241228072009_fix_relation_files/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "FileUploadRecord_clubId_key";

-- DropIndex
DROP INDEX "FileUploadRecord_fileUploadId_key";
2 changes: 1 addition & 1 deletion db/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
Loading

0 comments on commit 7521890

Please sign in to comment.