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

3
admin/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:8000
VITE_APP_NAME="Event Video Uploader - Admin"
VITE_UPLOAD_APP_URL=http://localhost:5174

24
admin/.gitignore vendored Normal file
View 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
admin/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
admin/README.md Normal file
View 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
admin/index.html Normal file
View 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 Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2403
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
admin/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vee-validate/zod": "^4.15.1",
"axios": "^1.13.4",
"bootstrap": "^5.3.8",
"pinia": "^3.0.4",
"vee-validate": "^4.15.1",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
admin/public/vite.svg Normal file
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="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

106
admin/src/App.vue Normal file
View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const notification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
onMounted(() => {
const params = new URLSearchParams(window.location.search)
if (params.has('google_drive_connected')) {
notification.value = { type: 'success', message: 'Google Drive connected successfully!' }
// Clean up URL
window.history.replaceState({}, '', window.location.pathname)
setTimeout(() => (notification.value = null), 5000)
} else if (params.has('google_drive_error')) {
const error = params.get('google_drive_error')
let message = 'Failed to connect Google Drive.'
if (error === 'not_authenticated') {
message = 'Session expired. Please log in again and try connecting Google Drive.'
} else if (error === 'missing_code') {
message = 'Authorization code missing. Please try again.'
} else if (error === 'connection_failed') {
message = 'Failed to connect Google Drive. Please try again.'
}
notification.value = { type: 'error', message }
// Clean up URL
window.history.replaceState({}, '', window.location.pathname)
setTimeout(() => (notification.value = null), 8000)
}
})
</script>
<template>
<div
v-if="notification"
class="global-notification"
role="alert"
>
<div
class="notification-toast"
:class="notification.type === 'success' ? 'notification-success' : 'notification-error'"
>
{{ notification.message }}
<button type="button" class="notification-close" @click="notification = null" aria-label="Close">×</button>
</div>
</div>
<router-view />
</template>
<style scoped>
.global-notification {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
max-width: 420px;
width: calc(100% - 2rem);
}
.notification-toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1rem;
border-radius: var(--admin-radius);
font-size: 0.9375rem;
font-weight: 500;
box-shadow: var(--admin-shadow-lg);
border: 1px solid transparent;
}
.notification-success {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
}
.notification-error {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
}
.notification-close {
background: none;
border: none;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
padding: 0 0.25rem;
}
.notification-close:hover {
opacity: 1;
}
</style>
<style>
#app {
min-height: 100vh;
}
</style>

1
admin/src/assets/vue.svg Normal file
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,193 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '../composables/useAuth'
const router = useRouter()
const route = useRoute()
const { user, logout } = useAuth()
const isScrolled = ref(false)
function onScroll() {
isScrolled.value = window.scrollY > 0
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll)
})
const pageTitle = computed(() => {
const name = route.name?.toString() ?? ''
if (name === 'events') return 'Events'
if (name === 'event-create') return 'Create Event'
if (name === 'event-edit') return 'Edit Event'
if (name === 'event-uploads') return 'Event Uploads'
return 'Admin'
})
async function handleLogout() {
await logout()
router.push('/login')
}
</script>
<template>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="sidebar-brand">
<span class="brand-icon"></span>
<span class="brand-text">Event Uploader</span>
</div>
<nav class="sidebar-nav">
<router-link to="/" class="nav-item" :class="{ active: route.name === 'events' }">
<span class="nav-icon"></span>
<span>Events</span>
</router-link>
<router-link to="/events/create" class="nav-item" :class="{ active: route.name === 'event-create' }">
<span class="nav-icon">+</span>
<span>Create Event</span>
</router-link>
</nav>
<div class="sidebar-footer">
<a
href="#"
class="nav-item"
@click.prevent="handleLogout"
>
<span class="nav-icon"></span>
<span>Logout</span>
</a>
</div>
</aside>
<main class="admin-main">
<header class="main-header" :class="{ 'main-header--scrolled': isScrolled }">
<h1 class="page-title">{{ pageTitle }}</h1>
<div class="header-actions">
<span class="user-email">{{ user?.email }}</span>
</div>
</header>
<div class="main-content">
<router-view />
</div>
</main>
</div>
</template>
<style scoped>
.admin-layout {
display: flex;
min-height: 100vh;
background: var(--admin-bg);
}
.admin-sidebar {
width: 260px;
flex-shrink: 0;
background: var(--admin-sidebar-bg);
color: var(--admin-sidebar-text);
display: flex;
flex-direction: column;
border-right: 1px solid var(--admin-sidebar-border);
}
.sidebar-brand {
padding: 1.5rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--admin-sidebar-border);
}
.brand-icon {
font-size: 1.5rem;
opacity: 0.9;
}
.brand-text {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.02em;
}
.sidebar-nav {
flex: 1;
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: var(--admin-radius);
color: var(--admin-sidebar-text-muted);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: var(--admin-sidebar-hover);
color: var(--admin-sidebar-text);
}
.nav-item.active {
background: var(--admin-sidebar-active);
color: var(--admin-primary);
}
.nav-icon {
font-size: 1rem;
width: 1.25rem;
text-align: center;
opacity: 0.8;
}
.sidebar-footer {
padding: 1rem 0.75rem;
border-top: 1px solid var(--admin-sidebar-border);
}
.main-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
background: var(--admin-surface);
border-bottom: 1px solid var(--admin-border);
position: sticky;
top: 0;
z-index: 10;
transition: box-shadow 0.15s ease;
}
.main-header--scrolled {
box-shadow: var(--admin-shadow);
}
.page-title {
margin: 0;
font-size: 1.375rem;
font-weight: 600;
color: var(--admin-heading);
letter-spacing: -0.02em;
}
.user-email {
font-size: 0.875rem;
color: var(--admin-muted);
}
.main-content {
padding: var(--admin-space-8);
flex: 1;
}
</style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{
message?: string
}>()
</script>
<template>
<div class="admin-card admin-card-body text-center py-5">
<div class="spinner-border text-primary" role="status" aria-label="Loading">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 mb-0 text-muted">{{ message ?? 'Loading...' }}</p>
</div>
</template>

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,68 @@
import { ref, computed } from 'vue'
import axios from 'axios'
import { api } from '../services/api'
import type { User } from '../types/user'
const user = ref<User | null>(null)
export function useAuth() {
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value)
async function fetchUser() {
loading.value = true
error.value = null
try {
const { data } = await api.get<User>('/auth/user')
user.value = data
return data
} catch (e) {
user.value = null
throw e
} finally {
loading.value = false
}
}
async function login(email: string, password: string, remember = false) {
loading.value = true
error.value = null
try {
const csrfBase = import.meta.env.DEV ? '' : (import.meta.env.VITE_API_URL || '')
await axios.get(csrfBase + '/sanctum/csrf-cookie', { withCredentials: true })
const { data } = await api.post<{ user: User }>('/auth/login', {
email,
password,
remember,
})
user.value = data.user
return data.user
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string }; status?: number } }
error.value = err.response?.data?.message ?? 'Login failed'
throw e
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.post('/auth/logout')
} finally {
user.value = null
}
}
return {
user: computed(() => user.value),
isAuthenticated,
loading,
error,
fetchUser,
login,
logout,
}
}

View File

@@ -0,0 +1,72 @@
import { ref } from 'vue'
import { api } from '../services/api'
import type { Event } from '../types/event'
interface PaginatedResponse<T> {
data: T[]
current_page: number
last_page: number
per_page: number
total: number
}
export function useEvents() {
const events = ref<Event[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const pagination = ref({ current_page: 1, last_page: 1, per_page: 15, total: 0 })
async function fetchEvents(page = 1) {
loading.value = true
error.value = null
try {
const { data } = await api.get<PaginatedResponse<Event>>('/admin/events', {
params: { page, per_page: 15 },
})
events.value = data.data
pagination.value = {
current_page: data.current_page,
last_page: data.last_page,
per_page: data.per_page,
total: data.total,
}
return data
} catch (e) {
error.value = 'Failed to load events'
throw e
} finally {
loading.value = false
}
}
async function createEvent(payload: Partial<Event>): Promise<Event> {
const { data } = await api.post<Event>('/admin/events', payload)
return data
}
async function updateEvent(id: number, payload: Partial<Event>): Promise<Event> {
const { data } = await api.put<Event>(`/admin/events/${id}`, payload)
return data
}
async function fetchEvent(id: number): Promise<Event> {
const { data } = await api.get<Event>(`/admin/events/${id}`)
return data
}
async function deleteEvent(id: number): Promise<void> {
await api.delete(`/admin/events/${id}`)
}
return {
events,
loading,
error,
pagination,
fetchEvents,
createEvent,
updateEvent,
fetchEvent,
deleteEvent,
}
}

View File

@@ -0,0 +1,85 @@
import { ref } from 'vue'
import { api } from '../services/api'
interface GoogleDriveStatus {
connected: boolean
account_email?: string
}
interface FolderItem {
id: string
name: string
}
interface SharedDrive {
id: string
name: string
type: 'shared_drive'
}
export function useGoogleDrive() {
const status = ref<GoogleDriveStatus | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchStatus() {
loading.value = true
error.value = null
try {
const { data } = await api.get<GoogleDriveStatus>('/admin/google-drive/status')
status.value = data
return data
} catch (e) {
error.value = 'Failed to load Google Drive status'
throw e
} finally {
loading.value = false
}
}
async function getAuthUrl(): Promise<string> {
const { data } = await api.get<{ url: string }>('/admin/google-drive/auth-url')
return data.url
}
async function disconnect() {
await api.delete('/admin/google-drive/disconnect')
status.value = { connected: false }
}
async function listSharedDrives(): Promise<SharedDrive[]> {
const { data } = await api.get<{ data: SharedDrive[] }>('/admin/google-drive/shared-drives')
return data.data
}
async function listFolders(parentId?: string, driveId?: string): Promise<FolderItem[]> {
const params: Record<string, string> = {}
if (parentId) params.parent_id = parentId
if (driveId) params.drive_id = driveId
const { data } = await api.get<{ data: FolderItem[] }>('/admin/google-drive/folders', {
params,
})
return data.data
}
async function createFolder(name: string, parentId?: string, driveId?: string): Promise<FolderItem> {
const { data } = await api.post<{ data: FolderItem }>('/admin/google-drive/folders', {
name,
parent_id: parentId,
drive_id: driveId,
})
return data.data
}
return {
status,
loading,
error,
fetchStatus,
getAuthUrl,
disconnect,
listSharedDrives,
listFolders,
createFolder,
}
}

12
admin/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useEvents } from '../composables/useEvents'
import { useGoogleDrive } from '../composables/useGoogleDrive'
import type { EventFormData } from '../types/event'
const router = useRouter()
const { createEvent, loading: saving, error } = useEvents()
const { fetchStatus, getAuthUrl, listSharedDrives, listFolders, createFolder } = useGoogleDrive()
const driveConnected = ref(false)
const sharedDrives = ref<{ id: string; name: string; type: string }[]>([])
const folders = ref<{ id: string; name: string }[]>([])
const selectedDriveId = ref('')
const selectedDriveName = ref('')
const selectedFolderId = ref('')
const selectedFolderName = ref('')
const viewMode = ref<'my-drive' | 'shared-drives'>('my-drive')
const form = reactive<Partial<EventFormData>>({
name: '',
description: '',
slug: '',
google_drive_folder_id: '',
google_drive_folder_name: '',
is_active: true,
upload_start_at: '',
upload_end_at: '',
max_file_size_mb: 500,
allowed_extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm'],
require_password: false,
upload_password: '',
})
const EXT_OPTIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'jpg', 'jpeg', 'png']
onMounted(async () => {
const s = await fetchStatus().catch(() => ({ connected: false }))
driveConnected.value = s?.connected ?? false
if (driveConnected.value) {
await loadMyDrive()
}
})
async function connectDrive() {
const url = await getAuthUrl()
window.location.href = url
}
async function loadMyDrive() {
viewMode.value = 'my-drive'
selectedDriveId.value = ''
selectedDriveName.value = ''
folders.value = await listFolders().catch(() => [])
}
async function loadSharedDrives() {
viewMode.value = 'shared-drives'
selectedDriveId.value = ''
selectedDriveName.value = ''
selectedFolderId.value = ''
selectedFolderName.value = ''
folders.value = []
sharedDrives.value = await listSharedDrives().catch(() => [])
}
function selectDrive(id: string, name: string) {
selectedDriveId.value = id
selectedDriveName.value = name
selectedFolderId.value = ''
selectedFolderName.value = ''
loadFoldersInDrive(id)
}
async function loadFoldersInDrive(driveId: string, parentId?: string) {
folders.value = await listFolders(parentId, driveId)
}
async function loadFolders(parentId?: string) {
if (selectedDriveId.value) {
folders.value = await listFolders(parentId, selectedDriveId.value)
} else {
folders.value = await listFolders(parentId)
}
}
function selectFolder(id: string, name: string) {
selectedFolderId.value = id
selectedFolderName.value = name
form.google_drive_folder_id = id
form.google_drive_folder_name = name
}
async function handleCreateFolder() {
const name = prompt('Folder name')
if (!name) return
const created = await createFolder(
name,
selectedFolderId.value || undefined,
selectedDriveId.value || undefined
)
await loadFolders(selectedFolderId.value || undefined)
selectFolder(created.id, created.name)
}
async function onSubmit() {
try {
const payload = {
...form,
google_drive_folder_id: form.google_drive_folder_id || null,
google_drive_folder_name: form.google_drive_folder_name || null,
upload_password: form.require_password ? form.upload_password : null,
}
const event = await createEvent(payload)
router.push({ name: 'event-uploads', params: { id: event.id } })
} catch (e) {
console.error(e)
}
}
</script>
<template>
<div class="admin-page admin-form-page">
<router-link to="/" class="back-link mb-4">
Back to Events
</router-link>
<div class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-4">Event details</h2>
<form @submit.prevent="onSubmit" class="row g-3">
<div v-if="error" class="col-12 alert alert-danger py-2">{{ error }}</div>
<div class="col-12">
<label class="form-label">Name</label>
<input v-model="form.name" type="text" class="form-control" required placeholder="e.g. Summer Conference 2025" />
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea v-model="form.description" class="form-control" rows="2" placeholder="Optional description for attendees"></textarea>
</div>
<div class="col-12">
<label class="form-label">URL slug <span class="text-muted">(optional, auto-generated from name)</span></label>
<input v-model="form.slug" type="text" class="form-control" placeholder="my-event" />
</div>
</form>
</div>
<div class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-3">Google Drive folder</h2>
<p class="text-muted small mb-3">Uploads will be saved to the selected folder.</p>
<div v-if="!driveConnected" class="drive-connect-box p-4 rounded-3 bg-light border">
<p class="mb-3 text-muted small">Connect your Google account to choose a destination folder.</p>
<button type="button" class="btn btn-primary" @click="connectDrive">
Connect Google Drive
</button>
</div>
<div v-else>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn"
:class="viewMode === 'my-drive' ? 'btn-primary' : 'btn-outline-primary'"
@click="loadMyDrive"
>
My Drive
</button>
<button
type="button"
class="btn"
:class="viewMode === 'shared-drives' ? 'btn-primary' : 'btn-outline-primary'"
@click="loadSharedDrives"
>
Shared Drives
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="loadFolders()">
Refresh
</button>
<button
v-if="viewMode === 'my-drive' || selectedDriveId"
type="button"
class="btn btn-sm btn-outline-primary"
@click="handleCreateFolder"
>
Create folder
</button>
</div>
<div v-if="viewMode === 'shared-drives' && !selectedDriveId">
<ul class="folder-list list-group">
<li
v-for="d in sharedDrives"
:key="d.id"
class="list-group-item list-group-item-action"
@click="selectDrive(d.id, d.name)"
>
📁 {{ d.name }}
</li>
</ul>
<p v-if="sharedDrives.length === 0" class="text-muted small mt-2 mb-0">No shared drives found</p>
</div>
<div v-if="viewMode === 'my-drive' || selectedDriveId">
<p v-if="selectedDriveName" class="text-muted small mb-2">
📁 {{ selectedDriveName }}
<button type="button" class="btn btn-sm btn-link p-0 ms-2" @click="loadSharedDrives">(change)</button>
</p>
<ul class="folder-list list-group">
<li
v-for="f in folders"
:key="f.id"
class="list-group-item list-group-item-action"
:class="{ active: selectedFolderId === f.id }"
@click="selectFolder(f.id, f.name)"
>
{{ f.name }}
</li>
</ul>
<p v-if="selectedFolderName" class="mt-2 text-muted small mb-0">
Selected: <strong>{{ selectedFolderName }}</strong>
</p>
</div>
</div>
</div>
<div class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-4">Settings</h2>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input v-model="form.is_active" type="checkbox" class="form-check-input" id="is_active" />
<label class="form-check-label" for="is_active">Event is active (accepting uploads)</label>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Max file size (MB)</label>
<input v-model.number="form.max_file_size_mb" type="number" class="form-control" min="1" max="2000" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Upload window start</label>
<input v-model="form.upload_start_at" type="datetime-local" class="form-control" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Upload window end</label>
<input v-model="form.upload_end_at" type="datetime-local" class="form-control" />
</div>
<div class="col-12">
<label class="form-label">Allowed file extensions</label>
<div class="d-flex flex-wrap gap-2">
<label v-for="ext in EXT_OPTIONS" :key="ext" class="form-check form-check-inline">
<input
type="checkbox"
class="form-check-input"
:value="ext"
:checked="form.allowed_extensions?.includes(ext)"
@change="(e: globalThis.Event) => {
const arr = form.allowed_extensions || []
const target = (e.target as HTMLInputElement)
if (target.checked) form.allowed_extensions = [...arr, ext]
else form.allowed_extensions = arr.filter(x => x !== ext)
}"
/>
<span class="form-check-label">.{{ ext }}</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch mb-2">
<input v-model="form.require_password" type="checkbox" class="form-check-input" id="require_password" />
<label class="form-check-label" for="require_password">Require upload password</label>
</div>
<input
v-if="form.require_password"
v-model="form.upload_password"
type="text"
class="form-control"
placeholder="Password for attendees"
minlength="4"
style="max-width: 280px"
/>
</div>
</div>
</div>
<div class="admin-card admin-card-body">
<button type="submit" class="btn btn-primary btn-lg" :disabled="saving" @click="onSubmit">
{{ saving ? 'Creating...' : 'Create Event' }}
</button>
</div>
</div>
</template>
<style scoped>
.admin-form-page {
max-width: 720px;
}
.back-link {
color: var(--admin-muted);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
display: inline-block;
}
.back-link:hover {
color: var(--admin-primary);
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--admin-heading);
margin: 0;
}
.folder-list {
max-height: 220px;
overflow-y: auto;
border-radius: var(--admin-radius);
}
.drive-connect-box {
border-color: var(--admin-border) !important;
}
</style>

View File

@@ -0,0 +1,339 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useEvents } from '../composables/useEvents'
import { useGoogleDrive } from '../composables/useGoogleDrive'
import type { Event as EventType } from '../types/event'
const router = useRouter()
const route = useRoute()
const id = computed(() => Number(route.params.id))
const { fetchEvent, updateEvent, loading: saving, error } = useEvents()
const { fetchStatus, getAuthUrl, listSharedDrives, listFolders, createFolder } = useGoogleDrive()
const event = ref<EventType | null>(null)
const driveConnected = ref(false)
const sharedDrives = ref<{ id: string; name: string; type: string }[]>([])
const folders = ref<{ id: string; name: string }[]>([])
const selectedDriveId = ref('')
const selectedDriveName = ref('')
const selectedFolderId = ref('')
const selectedFolderName = ref('')
const viewMode = ref<'my-drive' | 'shared-drives'>('my-drive')
const form = reactive<Partial<EventType>>({
name: '',
description: '',
slug: '',
google_drive_folder_id: '',
google_drive_folder_name: '',
is_active: true,
upload_start_at: '',
upload_end_at: '',
max_file_size_mb: 500,
allowed_extensions: [],
require_password: false,
upload_password: '',
})
const EXT_OPTIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'jpg', 'jpeg', 'png']
onMounted(async () => {
event.value = await fetchEvent(id.value)
Object.assign(form, {
...event.value,
upload_start_at: event.value.upload_start_at
? new Date(event.value.upload_start_at).toISOString().slice(0, 16)
: '',
upload_end_at: event.value.upload_end_at
? new Date(event.value.upload_end_at).toISOString().slice(0, 16)
: '',
upload_password: '',
})
selectedFolderId.value = event.value.google_drive_folder_id || ''
selectedFolderName.value = event.value.google_drive_folder_name || ''
const s = await fetchStatus().catch(() => ({ connected: false }))
driveConnected.value = s?.connected ?? false
if (driveConnected.value) folders.value = await listFolders().catch(() => [])
})
async function connectDrive() {
const url = await getAuthUrl()
window.location.href = url
}
async function loadMyDrive() {
viewMode.value = 'my-drive'
selectedDriveId.value = ''
selectedDriveName.value = ''
folders.value = await listFolders().catch(() => [])
}
async function loadSharedDrives() {
viewMode.value = 'shared-drives'
selectedDriveId.value = ''
selectedFolderId.value = ''
selectedFolderName.value = ''
folders.value = []
sharedDrives.value = await listSharedDrives().catch(() => [])
}
function selectDrive(id: string, name: string) {
selectedDriveId.value = id
selectedDriveName.value = name
selectedFolderId.value = ''
selectedFolderName.value = ''
loadFoldersInDrive(id)
}
async function loadFoldersInDrive(driveId: string, parentId?: string) {
folders.value = await listFolders(parentId, driveId)
}
async function loadFolders(parentId?: string) {
if (selectedDriveId.value) {
folders.value = await listFolders(parentId, selectedDriveId.value)
} else {
folders.value = await listFolders(parentId)
}
}
function selectFolder(fid: string, name: string) {
selectedFolderId.value = fid
selectedFolderName.value = name
form.google_drive_folder_id = fid
form.google_drive_folder_name = name
}
async function handleCreateFolder() {
const name = prompt('Folder name')
if (!name) return
const created = await createFolder(
name,
selectedFolderId.value || undefined,
selectedDriveId.value || undefined
)
await loadFolders(selectedFolderId.value || undefined)
selectFolder(created.id, created.name)
}
async function onSubmit() {
try {
const payload = {
...form,
google_drive_folder_id: form.google_drive_folder_id || null,
google_drive_folder_name: form.google_drive_folder_name || null,
upload_password: form.require_password && form.upload_password ? form.upload_password : undefined,
}
await updateEvent(id.value, payload)
router.push('/')
} catch (e) {
console.error(e)
}
}
</script>
<template>
<div class="admin-page admin-form-page">
<router-link to="/" class="back-link mb-4">
Back to Events
</router-link>
<div v-if="event" class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-4">Event details</h2>
<form @submit.prevent="onSubmit" class="row g-3">
<div v-if="error" class="col-12 alert alert-danger py-2">{{ error }}</div>
<div class="col-12">
<label class="form-label">Name</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="col-12">
<label class="form-label">URL slug</label>
<input v-model="form.slug" type="text" class="form-control" />
</div>
</form>
</div>
<div v-if="event" class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-3">Google Drive folder</h2>
<div v-if="!driveConnected" class="drive-connect-box p-4 rounded-3 bg-light border">
<button type="button" class="btn btn-primary" @click="connectDrive">
Connect Google Drive
</button>
</div>
<div v-else>
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn"
:class="viewMode === 'my-drive' ? 'btn-primary' : 'btn-outline-primary'"
@click="loadMyDrive"
>
My Drive
</button>
<button
type="button"
class="btn"
:class="viewMode === 'shared-drives' ? 'btn-primary' : 'btn-outline-primary'"
@click="loadSharedDrives"
>
Shared Drives
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="loadFolders()">
Refresh
</button>
<button
v-if="viewMode === 'my-drive' || selectedDriveId"
type="button"
class="btn btn-sm btn-outline-primary"
@click="handleCreateFolder"
>
Create folder
</button>
</div>
<div v-if="viewMode === 'shared-drives' && !selectedDriveId">
<ul class="folder-list list-group">
<li
v-for="d in sharedDrives"
:key="d.id"
class="list-group-item list-group-item-action"
@click="selectDrive(d.id, d.name)"
>
📁 {{ d.name }}
</li>
</ul>
<p v-if="sharedDrives.length === 0" class="text-muted small mt-2 mb-0">No shared drives found</p>
</div>
<div v-if="viewMode === 'my-drive' || selectedDriveId">
<p v-if="selectedDriveName" class="text-muted small mb-2">
📁 {{ selectedDriveName }}
<button type="button" class="btn btn-sm btn-link p-0 ms-2" @click="loadSharedDrives">(change)</button>
</p>
<ul class="folder-list list-group">
<li
v-for="f in folders"
:key="f.id"
class="list-group-item list-group-item-action"
:class="{ active: selectedFolderId === f.id }"
@click="selectFolder(f.id, f.name)"
>
{{ f.name }}
</li>
</ul>
<p v-if="selectedFolderName" class="mt-2 text-muted small mb-0">
Selected: <strong>{{ selectedFolderName }}</strong>
</p>
</div>
</div>
</div>
<div v-if="event" class="admin-card admin-card-body mb-4">
<h2 class="section-title mb-4">Settings</h2>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input v-model="form.is_active" type="checkbox" class="form-check-input" id="is_active" />
<label class="form-check-label" for="is_active">Event is active</label>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Max file size (MB)</label>
<input v-model.number="form.max_file_size_mb" type="number" class="form-control" min="1" max="2000" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Upload window start</label>
<input v-model="form.upload_start_at" type="datetime-local" class="form-control" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Upload window end</label>
<input v-model="form.upload_end_at" type="datetime-local" class="form-control" />
</div>
<div class="col-12">
<label class="form-label">Allowed extensions</label>
<div class="d-flex flex-wrap gap-2">
<label v-for="ext in EXT_OPTIONS" :key="ext" class="form-check form-check-inline">
<input
type="checkbox"
class="form-check-input"
:value="ext"
:checked="form.allowed_extensions?.includes(ext)"
@change="(e: globalThis.Event) => {
const arr = form.allowed_extensions || []
const target = (e.target as HTMLInputElement)
if (target.checked) form.allowed_extensions = [...arr, ext]
else form.allowed_extensions = arr.filter(x => x !== ext)
}"
/>
<span class="form-check-label">.{{ ext }}</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch mb-2">
<input v-model="form.require_password" type="checkbox" class="form-check-input" id="require_password" />
<label class="form-check-label" for="require_password">Require upload password</label>
</div>
<input
v-if="form.require_password"
v-model="form.upload_password"
type="text"
class="form-control"
placeholder="New password (leave blank to keep)"
minlength="4"
style="max-width: 280px"
/>
</div>
</div>
</div>
<div v-if="event" class="admin-card admin-card-body">
<button type="submit" class="btn btn-primary btn-lg" :disabled="saving" @click="onSubmit">
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
</div>
</div>
</template>
<style scoped>
.admin-form-page {
max-width: 720px;
}
.back-link {
color: var(--admin-muted);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
display: inline-block;
}
.back-link:hover {
color: var(--admin-primary);
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--admin-heading);
margin: 0;
}
.folder-list {
max-height: 220px;
overflow-y: auto;
border-radius: var(--admin-radius);
}
.drive-connect-box {
border-color: var(--admin-border) !important;
}
</style>

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminLoading from '../components/AdminLoading.vue'
import { useEvents } from '../composables/useEvents'
import { api } from '../services/api'
import type { Event } from '../types/event'
import type { Upload } from '../types/upload'
const route = useRoute()
const router = useRouter()
const eventId = computed(() => Number(route.params.id))
const { fetchEvent } = useEvents()
const event = ref<Event | null>(null)
const uploads = ref<Upload[]>([])
const loading = ref(true)
onMounted(async () => {
event.value = await fetchEvent(eventId.value)
await loadUploads()
})
async function loadUploads() {
loading.value = true
const { data } = await api.get<{ data: Upload[] }>(`/admin/events/${eventId.value}/uploads`, {
params: { per_page: 100 },
})
uploads.value = data.data
loading.value = false
}
function copyUploadUrl() {
if (!event.value) return
const url = `${import.meta.env.VITE_UPLOAD_APP_URL || 'http://localhost:5174'}/events/${event.value.slug}`
navigator.clipboard.writeText(url)
alert('Upload URL copied to clipboard')
}
async function getDownloadUrl(upload: Upload) {
const { data } = await api.get<{ url: string }>(`/admin/uploads/${upload.id}/download-url`)
if (data.url) window.open(data.url, '_blank')
}
async function deleteUpload(upload: Upload) {
if (!confirm(`Delete "${upload.original_filename}"?`)) return
await api.delete(`/admin/uploads/${upload.id}`)
await loadUploads()
}
function statusBadge(status: string) {
const map: Record<string, string> = {
pending: 'secondary',
uploading: 'info',
completed: 'success',
failed: 'danger',
}
return map[status] || 'secondary'
}
</script>
<template>
<div class="admin-page">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<router-link to="/" class="back-link"> Back to Events</router-link>
<button class="btn btn-outline-primary" @click="copyUploadUrl">
Copy upload URL
</button>
</div>
<AdminLoading v-if="loading" message="Loading uploads..." />
<div v-else-if="uploads.length === 0" class="admin-card admin-card-body">
<div class="empty-state">
<span class="empty-icon"></span>
<h3 class="h5 mb-2">No uploads yet</h3>
<p class="text-muted mb-0">Share the upload URL with attendees to start receiving files.</p>
</div>
</div>
<div v-else class="admin-card overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Status</th>
<th>Uploader</th>
<th>Date</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in uploads" :key="u.id">
<td>
<span class="fw-medium text-dark">{{ u.original_filename }}</span>
</td>
<td>{{ (u.file_size / 1024 / 1024).toFixed(2) }} MB</td>
<td>
<span :class="'badge bg-' + statusBadge(u.status)">{{ u.status }}</span>
<span v-if="u.error_message" class="text-danger small ms-1" :title="u.error_message">!</span>
</td>
<td>{{ u.uploader_name || u.uploader_email || '' }}</td>
<td>{{ new Date(u.created_at).toLocaleString() }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button
v-if="u.google_drive_web_link"
class="btn btn-outline-primary"
@click="window.open(u.google_drive_web_link!, '_blank')"
>
Open in Drive
</button>
<button
v-if="u.google_drive_file_id"
class="btn btn-outline-secondary"
@click="getDownloadUrl(u)"
>
Download
</button>
<button class="btn btn-outline-danger" @click="deleteUpload(u)">
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.admin-page {
max-width: 1200px;
}
.back-link {
color: var(--admin-muted);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
}
.back-link:hover {
color: var(--admin-primary);
}
.table th {
font-weight: 600;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--admin-muted);
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.table td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.empty-state {
text-align: center;
padding: 1rem 0;
}
.empty-icon {
font-size: 2.5rem;
opacity: 0.3;
display: block;
margin-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AdminLoading from '../components/AdminLoading.vue'
import { useEvents } from '../composables/useEvents'
const router = useRouter()
const { events, loading, pagination, fetchEvents, deleteEvent } = useEvents()
onMounted(() => fetchEvents())
async function handleDelete(id: number, name: string) {
if (!confirm(`Delete event "${name}"? This will delete all uploads.`)) return
try {
await deleteEvent(id)
await fetchEvents(pagination.value.current_page)
} catch (e) {
alert('Failed to delete event')
}
}
function copyUploadUrl(slug: string) {
const url = `${import.meta.env.VITE_UPLOAD_APP_URL || 'http://localhost:5174'}/events/${slug}`
navigator.clipboard.writeText(url)
alert('Upload URL copied to clipboard')
}
</script>
<template>
<div class="admin-page">
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">Manage events and share upload links with attendees.</p>
<router-link to="/events/create" class="btn btn-primary">
Create Event
</router-link>
</div>
<AdminLoading v-if="loading" message="Loading events..." />
<div v-else-if="events.length === 0" class="admin-card admin-card-body">
<div class="empty-state">
<span class="empty-icon"></span>
<h3 class="h5 mb-2">No events yet</h3>
<p class="text-muted mb-4">Create your first event to get a shareable upload link.</p>
<router-link to="/events/create" class="btn btn-primary">Create Event</router-link>
</div>
</div>
<div v-else class="admin-card overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Uploads</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="ev in events" :key="ev.id">
<td>
<span class="fw-medium text-dark">{{ ev.name }}</span>
</td>
<td><code class="slug">{{ ev.slug }}</code></td>
<td>
<span :class="ev.is_active ? 'badge bg-success' : 'badge bg-secondary'">
{{ ev.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td>{{ ev.uploads_count ?? 0 }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button
class="btn btn-outline-primary"
@click="copyUploadUrl(ev.slug)"
title="Copy upload URL"
>
Copy URL
</button>
<router-link
:to="{ name: 'event-uploads', params: { id: ev.id } }"
class="btn btn-outline-secondary"
>
Uploads
</router-link>
<router-link
:to="{ name: 'event-edit', params: { id: ev.id } }"
class="btn btn-outline-secondary"
>
Edit
</router-link>
<button
class="btn btn-outline-danger"
@click="handleDelete(ev.id, ev.name)"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.admin-page {
max-width: 1200px;
}
.table th {
font-weight: 600;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--admin-muted);
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.table td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.slug {
font-size: 0.875rem;
background: var(--admin-bg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 1rem 0;
}
.empty-icon {
font-size: 2.5rem;
opacity: 0.3;
display: block;
margin-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '../composables/useAuth'
const router = useRouter()
const route = useRoute()
const { login, loading, error } = useAuth()
const form = reactive({
email: '',
password: '',
remember: false,
})
async function onSubmit() {
try {
await login(form.email, form.password, form.remember)
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch {
// error handled in composable
}
}
</script>
<template>
<div class="login-page">
<div class="login-card card shadow-sm">
<div class="card-body p-5">
<div class="login-header mb-4">
<span class="login-logo"></span>
<h1 class="h4 mb-1 mt-2">Event Video Uploader</h1>
<p class="text-muted small mb-0">Sign in to the admin dashboard</p>
</div>
<form @submit.prevent="onSubmit">
<div v-if="error" class="alert alert-danger py-2 mb-3" role="alert">
{{ error }}
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
id="email"
v-model="form.email"
type="email"
class="form-control form-control-lg"
required
autocomplete="email"
placeholder="you@example.com"
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
id="password"
v-model="form.password"
type="password"
class="form-control form-control-lg"
required
autocomplete="current-password"
placeholder="••••••••"
/>
</div>
<div class="mb-4 form-check">
<input
id="remember"
v-model="form.remember"
type="checkbox"
class="form-check-input"
/>
<label class="form-check-label" for="remember">Remember me</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100 py-2" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(160deg, #f1f5f9 0%, #e2e8f0 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--admin-border);
}
.login-header {
text-align: center;
}
.login-logo {
font-size: 2.5rem;
opacity: 0.9;
display: inline-block;
}
.login-header .h4 {
font-weight: 600;
color: var(--admin-heading);
letter-spacing: -0.02em;
}
</style>

60
admin/src/router/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '../composables/useAuth'
import AdminLayout from '../components/AdminLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../pages/LoginPage.vue'),
meta: { public: true },
},
{
path: '/',
component: AdminLayout,
children: [
{
path: '',
name: 'events',
component: () => import('../pages/EventsListPage.vue'),
},
{
path: 'events/create',
name: 'event-create',
component: () => import('../pages/EventCreatePage.vue'),
},
{
path: 'events/:id/edit',
name: 'event-edit',
component: () => import('../pages/EventEditPage.vue'),
},
{
path: 'events/:id/uploads',
name: 'event-uploads',
component: () => import('../pages/EventUploadsPage.vue'),
},
],
},
],
})
router.beforeEach(async (to) => {
const { isAuthenticated, fetchUser } = useAuth()
if (to.name === 'login' && isAuthenticated.value) {
const redirect = (to.query.redirect as string) || '/'
return { path: redirect }
}
if (!to.meta.public) {
if (isAuthenticated.value) return true
try {
await fetchUser()
} catch {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
return true
})
export default router

34
admin/src/services/api.ts Normal file
View File

@@ -0,0 +1,34 @@
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
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/')) {
window.location.href = '/login'
}
return Promise.reject(error)
}
)

144
admin/src/style.css Normal file
View File

@@ -0,0 +1,144 @@
/* Event Uploader Admin Professional theme */
@import "bootstrap/dist/css/bootstrap.min.css";
:root {
/* Colors */
--admin-bg: #f1f5f9;
--admin-surface: #ffffff;
--admin-border: #e2e8f0;
--admin-sidebar-bg: #1e293b;
--admin-sidebar-text: #f1f5f9;
--admin-sidebar-text-muted: #94a3b8;
--admin-sidebar-border: rgba(255, 255, 255, 0.06);
--admin-sidebar-hover: rgba(255, 255, 255, 0.08);
--admin-sidebar-active: rgba(99, 102, 241, 0.15);
--admin-primary: #6366f1;
--admin-primary-hover: #4f46e5;
--admin-heading: #0f172a;
--admin-body: #334155;
--admin-muted: #64748b;
/* Radius */
--admin-radius: 8px;
--admin-radius-lg: 12px;
/* Elevation aligned with Upload app */
--admin-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
--admin-shadow-md: 0 2px 4px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--admin-shadow-lg: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
/* Typography scale */
--admin-text-xs: 0.75rem;
--admin-text-sm: 0.875rem;
--admin-text-base: 1rem;
--admin-text-lg: 1.125rem;
--admin-text-xl: 1.25rem;
--admin-text-2xl: 1.5rem;
/* Spacing scale */
--admin-space-2: 0.5rem;
--admin-space-3: 0.75rem;
--admin-space-4: 1rem;
--admin-space-5: 1.25rem;
--admin-space-6: 1.5rem;
--admin-space-8: 2rem;
--admin-space-10: 2.5rem;
--admin-space-12: 3rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
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(--admin-body);
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
}
/* Global admin cards used by EventsListPage, EventCreatePage, EventEditPage, EventUploadsPage */
.admin-card {
background: var(--admin-surface);
border-radius: var(--admin-radius-lg);
border: 1px solid var(--admin-border);
box-shadow: var(--admin-shadow);
}
.admin-card-body {
padding: var(--admin-space-8);
}
/* Button transitions */
.btn {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
/* Override Bootstrap for theme primary color for buttons, links, spinner */
.text-primary {
color: var(--admin-primary) !important;
}
.btn-primary {
background: var(--admin-primary);
border-color: var(--admin-primary);
}
.btn-primary:hover {
background: var(--admin-primary-hover);
border-color: var(--admin-primary-hover);
}
/* Form control focus transition */
.form-control,
.form-select {
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-control:focus,
.form-select:focus {
border-color: var(--admin-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.card {
border-radius: var(--admin-radius-lg);
border: 1px solid var(--admin-border);
box-shadow: var(--admin-shadow);
}
.table {
--bs-table-striped-bg: rgba(241, 245, 249, 0.6);
}
.badge.bg-success { background: #10b981 !important; }
.badge.bg-danger { background: #ef4444 !important; }
.badge.bg-info { background: #0ea5e9 !important; }
.badge.bg-secondary { background: var(--admin-muted) !important; }
.alert {
border-radius: var(--admin-radius);
border: 1px solid transparent;
}
.list-group-item {
border-color: var(--admin-border);
}
.list-group-item.active {
background: var(--admin-sidebar-active);
border-color: var(--admin-border);
color: var(--admin-primary);
}
.navbar {
display: none; /* We use AdminLayout instead */
}
/* Micro-interactions: links and table rows */
.admin-main a {
transition: color 0.15s ease;
}
.table tbody tr {
transition: background-color 0.15s ease;
}

33
admin/src/types/event.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface Event {
id: number
name: string
description: string | null
slug: string
google_drive_folder_id: string | null
google_drive_folder_name: string | null
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
created_at: string
updated_at: string
uploads_count?: number
}
export interface EventFormData {
name: string
description: string
slug: string
google_drive_folder_id: string
google_drive_folder_name: string
is_active: boolean
upload_start_at: string
upload_end_at: string
max_file_size_mb: number
allowed_extensions: string[]
require_password: boolean
upload_password: string
}

18
admin/src/types/upload.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface Upload {
id: number
event_id: number
original_filename: string
stored_filename: string
file_size: number
mime_type: string
google_drive_file_id: string | null
google_drive_web_link: string | null
status: 'pending' | 'uploading' | 'completed' | 'failed'
error_message: string | null
uploader_name: string | null
uploader_email: string | null
upload_started_at: string | null
upload_completed_at: string | null
created_at: string
updated_at: string
}

8
admin/src/types/user.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface User {
id: number
name: string
email: string
email_verified_at: string | null
created_at: string
updated_at: string
}

16
admin/tsconfig.app.json Normal file
View 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
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
admin/tsconfig.node.json Normal file
View 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"]
}

48
admin/vite.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
const setCookie = proxyRes.headers['set-cookie']
if (setCookie) {
const rewritten = (Array.isArray(setCookie) ? setCookie : [setCookie]).map(
(cookie: string) =>
cookie
.replace(/;\s*[Dd]omain=[^;]+/g, '; Domain=localhost')
.replace(/;\s*[Ss]ecure\b/g, ''),
)
proxyRes.headers['set-cookie'] = rewritten
}
})
},
},
'/sanctum': {
target: 'http://localhost:8000',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
const setCookie = proxyRes.headers['set-cookie']
if (setCookie) {
const rewritten = (Array.isArray(setCookie) ? setCookie : [setCookie]).map(
(cookie: string) =>
cookie
.replace(/;\s*[Dd]omain=[^;]+/g, '; Domain=localhost')
.replace(/;\s*[Ss]ecure\b/g, ''),
)
proxyRes.headers['set-cookie'] = rewritten
}
})
},
},
},
},
})