Initial commit
This commit is contained in:
3
admin/.env.example
Normal file
3
admin/.env.example
Normal 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
24
admin/.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
admin/.vscode/extensions.json
vendored
Normal file
3
admin/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
admin/README.md
Normal file
5
admin/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
admin/index.html
Normal file
16
admin/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 – 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
2403
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
admin/package.json
Normal file
29
admin/package.json
Normal 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
1
admin/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 |
106
admin/src/App.vue
Normal file
106
admin/src/App.vue
Normal 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
1
admin/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 |
193
admin/src/components/AdminLayout.vue
Normal file
193
admin/src/components/AdminLayout.vue
Normal 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>
|
||||
14
admin/src/components/AdminLoading.vue
Normal file
14
admin/src/components/AdminLoading.vue
Normal 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>
|
||||
41
admin/src/components/HelloWorld.vue
Normal file
41
admin/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>
|
||||
68
admin/src/composables/useAuth.ts
Normal file
68
admin/src/composables/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
72
admin/src/composables/useEvents.ts
Normal file
72
admin/src/composables/useEvents.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
85
admin/src/composables/useGoogleDrive.ts
Normal file
85
admin/src/composables/useGoogleDrive.ts
Normal 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
12
admin/src/main.ts
Normal 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')
|
||||
328
admin/src/pages/EventCreatePage.vue
Normal file
328
admin/src/pages/EventCreatePage.vue
Normal 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>
|
||||
339
admin/src/pages/EventEditPage.vue
Normal file
339
admin/src/pages/EventEditPage.vue
Normal 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>
|
||||
181
admin/src/pages/EventUploadsPage.vue
Normal file
181
admin/src/pages/EventUploadsPage.vue
Normal 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>
|
||||
152
admin/src/pages/EventsListPage.vue
Normal file
152
admin/src/pages/EventsListPage.vue
Normal 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>
|
||||
114
admin/src/pages/LoginPage.vue
Normal file
114
admin/src/pages/LoginPage.vue
Normal 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
60
admin/src/router/index.ts
Normal 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
34
admin/src/services/api.ts
Normal 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
144
admin/src/style.css
Normal 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
33
admin/src/types/event.ts
Normal 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
18
admin/src/types/upload.ts
Normal 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
8
admin/src/types/user.ts
Normal 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
16
admin/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
admin/tsconfig.json
Normal file
7
admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
admin/tsconfig.node.json
Normal file
26
admin/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"]
|
||||
}
|
||||
48
admin/vite.config.ts
Normal file
48
admin/vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user