Initial commit
This commit is contained in:
2
upload/.env.example
Normal file
2
upload/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_APP_NAME="Upload Your Videos"
|
||||
24
upload/.gitignore
vendored
Normal file
24
upload/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
upload/.vscode/extensions.json
vendored
Normal file
3
upload/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
upload/README.md
Normal file
5
upload/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
16
upload/index.html
Normal file
16
upload/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>Event Video Uploader</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3163
upload/package-lock.json
generated
Normal file
3163
upload/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
upload/package.json
Normal file
29
upload/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "upload",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.0",
|
||||
"@uppy/xhr-upload": "^5.1.1",
|
||||
"axios": "^1.13.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
1
upload/public/vite.svg
Normal file
1
upload/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
6
upload/src/App.vue
Normal file
6
upload/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
1
upload/src/assets/vue.svg
Normal file
1
upload/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
189
upload/src/components/FileUploader.vue
Normal file
189
upload/src/components/FileUploader.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Uppy from '@uppy/core'
|
||||
import Dashboard from '@uppy/dashboard'
|
||||
import XHRUpload from '@uppy/xhr-upload'
|
||||
import type { PublicEvent } from '../types/event'
|
||||
import { usePasswordVerification } from '../composables/usePasswordVerification'
|
||||
|
||||
const props = defineProps<{
|
||||
event: PublicEvent
|
||||
slug: string
|
||||
}>()
|
||||
|
||||
const { getPassword } = usePasswordVerification(props.slug)
|
||||
const uppyContainer = ref<HTMLElement | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
let successTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let uppy: Uppy | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!uppyContainer.value || !props.event) return
|
||||
|
||||
const baseURL = import.meta.env.DEV ? '' : (import.meta.env.VITE_API_URL || '')
|
||||
const endpoint = `${baseURL}/api/events/${props.slug}/upload`
|
||||
|
||||
uppy = new Uppy({
|
||||
restrictions: {
|
||||
maxFileSize: props.event.max_file_size_mb * 1024 * 1024,
|
||||
allowedFileTypes: props.event.allowed_extensions.map((ext) => `.${ext}`),
|
||||
},
|
||||
autoProceed: false,
|
||||
})
|
||||
|
||||
uppy.use(Dashboard, {
|
||||
target: uppyContainer.value,
|
||||
inline: true,
|
||||
proudlyDisplayPoweredByUppy: false,
|
||||
showProgressDetails: true,
|
||||
height: 360,
|
||||
theme: 'light',
|
||||
})
|
||||
|
||||
uppy.use(XHRUpload, {
|
||||
endpoint,
|
||||
method: 'POST',
|
||||
formData: true,
|
||||
fieldName: 'file',
|
||||
withCredentials: true,
|
||||
getResponseData() {
|
||||
return {}
|
||||
},
|
||||
getRequestHeaders() {
|
||||
const headers: Record<string, string> = {}
|
||||
const password = getPassword()
|
||||
if (password) {
|
||||
headers['X-Upload-Password'] = password
|
||||
}
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
if (token) {
|
||||
headers['X-XSRF-TOKEN'] = decodeURIComponent(token.split('=')[1])
|
||||
}
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
uppy.on('complete', () => {
|
||||
showSuccess.value = true
|
||||
if (successTimeout) clearTimeout(successTimeout)
|
||||
successTimeout = setTimeout(() => {
|
||||
showSuccess.value = false
|
||||
successTimeout = null
|
||||
}, 4000)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (successTimeout) clearTimeout(successTimeout)
|
||||
uppy?.close()
|
||||
uppy = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.slug,
|
||||
() => {
|
||||
uppy?.close()
|
||||
uppy = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-uploader-wrapper">
|
||||
<Transition name="upload-success">
|
||||
<div v-if="showSuccess" class="upload-success-banner" role="status" aria-live="polite">
|
||||
<span class="upload-success-icon" aria-hidden="true">✓</span>
|
||||
<span>Upload complete</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<div ref="uppyContainer" class="uppy-dashboard-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-uploader-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-success-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid var(--upload-primary);
|
||||
border-radius: var(--upload-radius);
|
||||
color: var(--upload-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.upload-success-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background: var(--upload-primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-success-enter-active,
|
||||
.upload-success-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.upload-success-enter-from,
|
||||
.upload-success-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.uppy-dashboard-container {
|
||||
--uppy-dashboard-accent-color: #6366f1;
|
||||
--uppy-dashboard-accent-color-hover: #4f46e5;
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-inner {
|
||||
border-radius: var(--upload-radius);
|
||||
border: 1px solid var(--upload-border);
|
||||
background: var(--upload-surface);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-AddFiles-info {
|
||||
font-family: "Plus Jakarta Sans", sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--upload-muted);
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-AddFiles-title {
|
||||
font-family: "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 600;
|
||||
color: var(--upload-heading);
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-browse {
|
||||
color: var(--upload-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-browse:hover {
|
||||
color: var(--upload-primary-hover);
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-Dashboard-Item-previewInnerWrap {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.uppy-dashboard-container .uppy-StatusBar.is-waiting .uppy-StatusBar-bar {
|
||||
background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
|
||||
}
|
||||
</style>
|
||||
41
upload/src/components/HelloWorld.vue
Normal file
41
upload/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
30
upload/src/composables/useEvent.ts
Normal file
30
upload/src/composables/useEvent.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
import type { PublicEvent } from '../types/event'
|
||||
|
||||
export function useEvent(slug: Ref<string> | (() => string)) {
|
||||
const getSlug = typeof slug === 'function' ? slug : () => slug.value
|
||||
const event = ref<PublicEvent | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchEvent() {
|
||||
const s = getSlug()
|
||||
if (!s) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await api.get<PublicEvent>(`/events/${s}`)
|
||||
event.value = data
|
||||
return data
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status: number } }
|
||||
error.value = err.response?.status === 404 ? 'Event not found' : 'Failed to load event'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { event, loading, error, fetchEvent }
|
||||
}
|
||||
51
upload/src/composables/usePasswordVerification.ts
Normal file
51
upload/src/composables/usePasswordVerification.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { api } from '../services/api'
|
||||
|
||||
const STORAGE_KEY = (s: string) => `event_password_${s}`
|
||||
|
||||
export function usePasswordVerification(slug: Ref<string> | (() => string) | string) {
|
||||
const getSlug =
|
||||
typeof slug === 'function'
|
||||
? slug
|
||||
: typeof slug === 'string'
|
||||
? () => slug
|
||||
: () => slug.value
|
||||
const stored = ref<string | null>(null)
|
||||
const isVerifying = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function readStored() {
|
||||
try {
|
||||
stored.value = sessionStorage.getItem(STORAGE_KEY(getSlug()))
|
||||
} catch {
|
||||
stored.value = null
|
||||
}
|
||||
}
|
||||
readStored()
|
||||
|
||||
const isVerified = computed(() => !!stored.value)
|
||||
|
||||
async function verifyPassword(password: string): Promise<boolean> {
|
||||
const s = getSlug()
|
||||
isVerifying.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await api.post(`/events/${s}/verify-password`, { password })
|
||||
sessionStorage.setItem(STORAGE_KEY(s), password)
|
||||
stored.value = password
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string }; status?: number } }
|
||||
error.value = err.response?.data?.message ?? 'Invalid password. Please try again.'
|
||||
return false
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPassword(): string | null {
|
||||
return stored.value ?? sessionStorage.getItem(STORAGE_KEY(getSlug()))
|
||||
}
|
||||
|
||||
return { isVerified, isVerifying, error, verifyPassword, getPassword }
|
||||
}
|
||||
7
upload/src/main.ts
Normal file
7
upload/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@uppy/dashboard/css/style.min.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
113
upload/src/pages/EventUpload.vue
Normal file
113
upload/src/pages/EventUpload.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useEvent } from '../composables/useEvent'
|
||||
import { usePasswordVerification } from '../composables/usePasswordVerification'
|
||||
import FileUploader from '../components/FileUploader.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
const { event, loading, error, fetchEvent } = useEvent(slug)
|
||||
const passwordVerification = usePasswordVerification(slug)
|
||||
const passwordInput = ref('')
|
||||
|
||||
onMounted(() => fetchEvent())
|
||||
watch(slug, () => fetchEvent())
|
||||
|
||||
const showPasswordGate = computed(
|
||||
() => event.value?.require_password && !passwordVerification.isVerified.value
|
||||
)
|
||||
|
||||
async function handlePasswordSubmit() {
|
||||
if (!passwordInput.value) return
|
||||
await passwordVerification.verifyPassword(passwordInput.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-[var(--upload-bg)] py-10 px-4 sm:py-14">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="upload-card text-center py-16">
|
||||
<div class="inline-block w-10 h-10 border-2 border-[var(--upload-primary)] border-t-transparent rounded-full animate-spin" role="status" aria-label="Loading"></div>
|
||||
<p class="mt-4 text-[var(--upload-muted)] font-medium">Loading event...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="upload-card border-red-200 bg-red-50/80 p-5 rounded-[var(--upload-radius)]">
|
||||
<p class="text-red-700 font-medium">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="event">
|
||||
<!-- Header -->
|
||||
<header class="text-center mb-10">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-[var(--upload-heading)] tracking-tight">
|
||||
{{ event.name }}
|
||||
</h1>
|
||||
<p v-if="event.description" class="mt-3 text-[var(--upload-muted)] text-lg max-w-lg mx-auto leading-relaxed">
|
||||
{{ event.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Password gate -->
|
||||
<div
|
||||
v-if="showPasswordGate"
|
||||
class="upload-card p-6 sm:p-8"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--upload-primary)]/10 text-[var(--upload-primary)] text-lg font-semibold">🔒</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--upload-heading)]">Password required</h2>
|
||||
<p class="text-sm text-[var(--upload-muted)] mt-0.5">
|
||||
Enter the event password to upload files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="handlePasswordSubmit" class="space-y-4">
|
||||
<input
|
||||
v-model="passwordInput"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
class="w-full rounded-lg border border-[var(--upload-border)] bg-white px-4 py-3 text-[var(--upload-body)] placeholder:text-[var(--upload-muted)] focus:border-[var(--upload-primary)] focus:ring-2 focus:ring-[var(--upload-primary)]/20 focus:outline-none"
|
||||
:disabled="passwordVerification.isVerifying.value"
|
||||
/>
|
||||
<p
|
||||
v-if="passwordVerification.error.value"
|
||||
class="text-sm text-red-600 font-medium"
|
||||
>
|
||||
{{ passwordVerification.error.value }}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-[var(--upload-primary)] px-4 py-3 font-semibold text-white hover:bg-[var(--upload-primary-hover)] focus:ring-2 focus:ring-[var(--upload-primary)] focus:ring-offset-2 focus:outline-none disabled:opacity-50"
|
||||
:disabled="passwordVerification.isVerifying.value"
|
||||
>
|
||||
{{ passwordVerification.isVerifying.value ? 'Verifying...' : 'Continue' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
v-else
|
||||
class="upload-card p-6 sm:p-8"
|
||||
>
|
||||
<p class="mb-5 text-sm text-[var(--upload-muted)]">
|
||||
Max file size: <strong class="text-[var(--upload-body)]">{{ event.max_file_size_mb }} MB</strong>
|
||||
· Allowed: <strong class="text-[var(--upload-body)]">{{ event.allowed_extensions.join(', ') }}</strong>
|
||||
</p>
|
||||
<FileUploader :event="event" :slug="event.slug" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-card {
|
||||
background: var(--upload-surface);
|
||||
border: 1px solid var(--upload-border);
|
||||
border-radius: var(--upload-radius);
|
||||
box-shadow: var(--upload-shadow-lg);
|
||||
}
|
||||
</style>
|
||||
18
upload/src/router/index.ts
Normal file
18
upload/src/router/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/events/:slug',
|
||||
name: 'event-upload',
|
||||
component: () => import('../pages/EventUpload.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'event-upload', params: { slug: 'demo' } },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
21
upload/src/services/api.ts
Normal file
21
upload/src/services/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const baseURL = import.meta.env.DEV
|
||||
? '/api'
|
||||
: (import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` : '/api')
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
if (token) {
|
||||
config.headers['X-XSRF-TOKEN'] = decodeURIComponent(token.split('=')[1])
|
||||
}
|
||||
return config
|
||||
})
|
||||
45
upload/src/style.css
Normal file
45
upload/src/style.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Aligned with Admin app: primary, radius, shadows for consistent product feel */
|
||||
:root {
|
||||
--upload-bg: #f8fafc;
|
||||
--upload-surface: #ffffff;
|
||||
--upload-border: #e2e8f0;
|
||||
--upload-primary: #6366f1;
|
||||
--upload-primary-hover: #4f46e5;
|
||||
--upload-heading: #0f172a;
|
||||
--upload-body: #334155;
|
||||
--upload-muted: #64748b;
|
||||
--upload-radius: 12px;
|
||||
--upload-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--upload-shadow-md: 0 2px 4px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--upload-shadow-lg: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--upload-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Transitions for interactive elements – aligned with Admin */
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
button,
|
||||
[role="button"] {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
12
upload/src/types/event.ts
Normal file
12
upload/src/types/event.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface PublicEvent {
|
||||
name: string
|
||||
description: string | null
|
||||
slug: string
|
||||
is_active: boolean
|
||||
upload_start_at: string | null
|
||||
upload_end_at: string | null
|
||||
max_file_size_mb: number
|
||||
allowed_extensions: string[]
|
||||
require_password: boolean
|
||||
has_password: boolean
|
||||
}
|
||||
16
upload/tsconfig.app.json
Normal file
16
upload/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
upload/tsconfig.json
Normal file
7
upload/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
upload/tsconfig.node.json
Normal file
26
upload/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
upload/vite.config.ts
Normal file
15
upload/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
'/sanctum': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user