Initial commit

This commit is contained in:
2026-02-03 10:38:46 +01:00
commit eb304f4b14
144 changed files with 22605 additions and 0 deletions

6
upload/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<router-view />
</template>

View 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

View 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>

View 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>

View 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 }
}

View 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
View 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')

View 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>

View 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

View 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
View 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
View 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
}