diff --git a/.github/workflows/buildCheck.yml b/.github/workflows/buildCheck.yml index 5508a7a0..9cefbc0a 100644 --- a/.github/workflows/buildCheck.yml +++ b/.github/workflows/buildCheck.yml @@ -17,18 +17,23 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10 + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - cache: 'npm' + cache: 'pnpm' - - name: Install Dependencies from Package-Lock - run: npm ci + - name: Install Dependencies + run: pnpm install --frozen-lockfile - name: Generate Prisma Client - run: npx prisma generate + run: pnpm prisma generate - name: Build Project - run: npm run build + run: pnpm build diff --git a/.gitignore b/.gitignore index b5e89df5..129bf30a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ prisma/generated prisma/dev.db prisma/dev.db-journal + +# Dev seed file +prisma/seed.ts + +# non-pnpm lock files +yarn.lock +package-lock.json diff --git a/app/components/ClassContextSelect.css b/app/components/ClassContextSelect.css new file mode 100644 index 00000000..00aa116d --- /dev/null +++ b/app/components/ClassContextSelect.css @@ -0,0 +1,43 @@ +.class-context-select { + display: flex; + align-items: center; + gap: 0.75rem; +} + +label { + color: #64748b; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +select { + min-width: 15rem; + padding: 0.7rem 0.9rem; + background: #f8fafc; + border: 1px solid #cbd5e1; + border-radius: 0.65rem; + color: #1e293b; + cursor: pointer; + font: inherit; + font-size: 0.9rem; + font-weight: 700; +} + +select:focus { + border-color: #4f46e5; + outline: none; + box-shadow: 0 0 0 3px rgb(79 70 229 / 12%); +} + +@media (max-width: 800px) { + .class-context-select { + width: 100%; + } + + select { + min-width: 0; + flex: 1; + } +} diff --git a/app/components/ClassContextSelect.vue b/app/components/ClassContextSelect.vue new file mode 100644 index 00000000..8d0fb0c1 --- /dev/null +++ b/app/components/ClassContextSelect.vue @@ -0,0 +1,162 @@ + + + + + Managing + + + Admin + + Loading classes… + + + {{ classroom.name }} + + + + Create New Class + + + + + diff --git a/app/components/LogoutButton.vue b/app/components/LogoutButton.vue new file mode 100644 index 00000000..39514f33 --- /dev/null +++ b/app/components/LogoutButton.vue @@ -0,0 +1,26 @@ + + + + + ⎋ Logout + + diff --git a/app/components/RichTextEditor.vue b/app/components/RichTextEditor.vue new file mode 100644 index 00000000..098ce217 --- /dev/null +++ b/app/components/RichTextEditor.vue @@ -0,0 +1,601 @@ + + + + + + + + + Font + {{ f }} + + + + + + + {{ s }} + + + + + + + + B + + + I + + + U + + + + + + + + A + + + + + + + + + + + Custom: + + + + + + + + + + + + + diff --git a/app/composables/useAdmin.ts b/app/composables/useCoach.ts similarity index 90% rename from app/composables/useAdmin.ts rename to app/composables/useCoach.ts index f5c76eb9..40abe82e 100644 --- a/app/composables/useAdmin.ts +++ b/app/composables/useCoach.ts @@ -1,13 +1,24 @@ -// composables/useAdmin.ts -// Place this at: app/composables/useAdmin.ts +// composables/useCoach.ts +// Place this at: app/composables/useCoach.ts import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' dayjs.extend(utc) -export const useAdmin = () => { +export const useCoach = () => { + const { classId } = useSelectedClass() const callFormApi = async (method: 'GET' | 'POST' | 'PUT' | 'DELETE', params: Record = {}, body?: Record): Promise => { + if (!classId.value) { + throw new Error('Select a class before managing forms') + } + + const scopedParams = { + ...params, + ...(method === 'DELETE' ? body : {}), + classId: classId.value, + } + const scopedBody = { ...body, classId: classId.value } const queryString = method === 'GET' || method === 'DELETE' - ? `?${new URLSearchParams(Object.entries(params).reduce((acc, [key, value]) => { + ? `?${new URLSearchParams(Object.entries(scopedParams).reduce((acc, [key, value]) => { if (value !== undefined && value !== null) { acc[key] = String(value) } @@ -18,7 +29,7 @@ export const useAdmin = () => { return await $fetch(`/api/form${queryString}`, { method, - body: method === 'GET' ? undefined : body, + body: method === 'GET' || method === 'DELETE' ? undefined : scopedBody, }) } @@ -215,6 +226,11 @@ export const useAdmin = () => { ) const loadPublishedForms = async () => { + if (!classId.value) { + publishedForms.value = [] + return + } + try { const forms = await callFormApi('GET', { action: 'listForms', @@ -227,6 +243,12 @@ export const useAdmin = () => { } const syncGroupRangeFromWeeklyDate = async () => { + if (!classId.value) { + historyGroupStartDate.value = '' + historyGroupEndDate.value = '' + return + } + if (!historyWeekStart.value) { historyGroupStartDate.value = '' historyGroupEndDate.value = '' @@ -262,8 +284,11 @@ export const useAdmin = () => { } watch( - historyWeekStart, + [historyWeekStart, classId], async () => { + publishedForms.value = [] + selectedFormDetails.value = null + editingFormId.value = null await syncGroupRangeFromWeeklyDate() await loadPublishedForms() }, @@ -433,7 +458,7 @@ export const useAdmin = () => { questions.value = JSON.parse(JSON.stringify(form.questions)) editingFormId.value = form.id builderSubTab.value = 'creation' - navigateTo('/admin/builder') + navigateTo({ path: '/coach/builder', query: { class: classId.value } }) } const toggleFormPublish = async (form: any) => { @@ -442,9 +467,10 @@ export const useAdmin = () => { form.status = form.status === 'Active' ? 'Unpublished' : 'Active' const newStatus = form.status === 'Active' ? true : false - await useFetch(`/api/form/${form.id}`, { - method: 'PUT', - body: {published: newStatus} + await callFormApi('PUT', {}, { + action: 'updateForm', + id: form.id, + published: newStatus, }) } @@ -453,11 +479,49 @@ export const useAdmin = () => { } // ── Students / Progress ── - const students = useState('adminStudents', () => [ - { id: 1, name: 'Aiden Smith', initials: 'AS', email: 'aiden@school.edu', tickets: 12, streak: 4, lastActive: '2 hours ago' }, - { id: 2, name: 'Nevin Kumar', initials: 'NK', email: 'nevin@school.edu', tickets: 14, streak: 5, lastActive: 'Just now' }, - { id: 3, name: 'Swarna Jay', initials: 'SJ', email: 'swarna@school.edu',tickets: 8, streak: 2, lastActive: 'Yesterday' }, - ]) + const students = useState('coachStudents', () => []) + + const loadStudents = async () => { + if (!classId.value) { + students.value = [] + return + } + + const classStudents = await $fetch>( + '/api/student', + { + method: 'GET', + query: { classId: classId.value }, + } + ) + + students.value = classStudents.map((student) => ({ + ...student, + initials: student.name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(''), + tickets: student.exp, + streak: 0, + lastActive: '', + })) + } + + watch( + classId, + async () => { + students.value = [] + + try { + await loadStudents() + } catch (error) { + console.error('Failed to load class students', error) + } + }, + { immediate: true } + ) const searchStudent = useState('searchStudent', () => '') const sortStudent = useState('sortStudent', () => 'tickets') diff --git a/app/composables/useCurrentFormGroup.ts b/app/composables/useCurrentFormGroup.ts index 7d445688..4985c2ad 100644 --- a/app/composables/useCurrentFormGroup.ts +++ b/app/composables/useCurrentFormGroup.ts @@ -27,19 +27,22 @@ export const useCurrentFormGroup = () => { const loadActiveFormGroup = async () => { try { - const formGroupAPIResponse = await $fetch('/api/formGroup?active=true') - - const activeFg = Array.isArray(formGroupAPIResponse) ? formGroupAPIResponse[0] : formGroupAPIResponse + const activeFormGroups = await $fetch('/api/formGroup?active=true') + const activeFg = activeFormGroups[0] if (activeFg) { FormGroup.value.activeFormGroup = activeFg try { - const formsAPIResponse = await $fetch('/api/form', { - query: { action: 'getOnlyActiveFormsinGroup', formGroup: activeFg.id } - }) + const formResponses = await Promise.all( + activeFormGroups.map((formGroup) => + $fetch('/api/form', { + query: { action: 'getOnlyActiveFormsinGroup', formGroup: formGroup.id } + }) + ) + ) - FormGroup.value.forms = Array.isArray(formsAPIResponse) ? formsAPIResponse : [] + FormGroup.value.forms = formResponses.flat() // Load form components for each form in parallel FormGroup.value.formComponents = {} @@ -72,4 +75,4 @@ export const useCurrentFormGroup = () => { loadFormComponents, totalFormsInGroup } -} \ No newline at end of file +} diff --git a/app/composables/useRaffleSpin.ts b/app/composables/useRaffleSpin.ts index 041c80f8..a5d00ff0 100644 --- a/app/composables/useRaffleSpin.ts +++ b/app/composables/useRaffleSpin.ts @@ -1,5 +1,6 @@ import type { Student, FormGroup, Form, FormSubmission } from '~~/prisma/generated/client' export const useRaffleSpin = () => { + const { classId } = useSelectedClass() const raffleWeekStart = ref(new Date()) @@ -10,9 +11,17 @@ export const useRaffleSpin = () => { const spinCount = ref(0) const loadRaffleFormGroup = async () => { + if (!classId.value) { + raffleFormGroup.value = null + return + } + const val = raffleWeekStart.value const dateStr = val instanceof Date ? val.toISOString().split('T')[0] : String(val).split('T')[0] - const data = await $fetch(`/api/formGroup?date=${dateStr}`, { method: 'GET' }) + const data = await $fetch('/api/formGroup', { + method: 'GET', + query: { date: dateStr, classId: classId.value }, + }) raffleFormGroup.value = data || null } @@ -21,7 +30,14 @@ export const useRaffleSpin = () => { raffleForms.value = [] return } - const data = await $fetch(`/api/form?action=listForms&formGroup=${raffleFormGroup.value.id}`, { method: 'GET' }) + const data = await $fetch('/api/form', { + method: 'GET', + query: { + action: 'listForms', + formGroup: raffleFormGroup.value.id, + classId: classId.value, + }, + }) raffleForms.value = data || [] } @@ -30,7 +46,10 @@ export const useRaffleSpin = () => { raffleSubmissions.value = [] return } - const data = await $fetch(`/api/formSubmission?formGroup=${raffleFormGroup.value.id}`, { method: 'GET' }) + const data = await $fetch('/api/formSubmission', { + method: 'GET', + query: { formGroup: raffleFormGroup.value.id, classId: classId.value }, + }) raffleSubmissions.value = data || [] } @@ -63,7 +82,8 @@ export const useRaffleSpin = () => { method: 'PUT', body: { id: raffleFormGroup.value.id, - raffleWinner: studentId + raffleWinner: studentId, + classId: classId.value, } }) await loadRaffleData() @@ -73,6 +93,14 @@ export const useRaffleSpin = () => { } } + watch(classId, async () => { + raffleFormGroup.value = null + raffleForms.value = [] + raffleSubmissions.value = [] + raffleWinner.value = null + await loadRaffleData() + }) + return { raffleWinner, raffleFormGroup, @@ -83,4 +111,4 @@ export const useRaffleSpin = () => { loadRaffleData, spinCount } -} \ No newline at end of file +} diff --git a/app/composables/useSelectedClass.ts b/app/composables/useSelectedClass.ts new file mode 100644 index 00000000..a38cfca4 --- /dev/null +++ b/app/composables/useSelectedClass.ts @@ -0,0 +1,18 @@ +export const useSelectedClass = () => { + const route = useRoute() + const storedClassId = useCookie('selected-class-token', { + sameSite: 'lax', + }) + + const classId = computed(() => { + const queryClassId = route.query.class + const normalizedQueryClassId = Array.isArray(queryClassId) ? queryClassId[0] : queryClassId + + return normalizedQueryClassId || storedClassId.value || null + }) + + return { + classId, + storedClassId, + } +} diff --git a/app/layouts/admin.css b/app/layouts/admin.css index 33ec929b..1b7abf41 100644 --- a/app/layouts/admin.css +++ b/app/layouts/admin.css @@ -1,235 +1,263 @@ -html, body { margin: 0; padding: 0; height: 100%; } -#__nuxt { height: 100%; } +html, +body, +#__nuxt { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #eef2f7; + color: #1e293b; + font-family: Quicksand, system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} -/* ── Shell ── */ -.rh-admin-wrap { +.portal-layout { display: flex; + width: 100%; height: 100vh; overflow: hidden; - background: #f8fafc; - font-family: 'Quicksand', system-ui, sans-serif; - color: #1e293b; + background: #eef2f7; } -/* ── Sidebar ── */ -.rh-sidebar { +.portal-sidebar { width: 18rem; + height: 100vh; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + background: #f8fafc; + border-right: 1px solid #dbe3ec; +} + +.portal-sidebar-content { + padding: 1.5rem; +} + +.portal-logo { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; + color: inherit; + text-decoration: none; +} + +.portal-logo-icon { + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; - background: #ffffff; - border-right: 1px solid #e2e8f0; + background: #4f46e5; + border-radius: 0.5rem; + color: #ffffff; + font-size: 1.25rem; + font-weight: 700; +} + +.portal-logo-title { + color: #0f172a; + font-size: 1.1rem; + font-weight: 700; +} + +.portal-logo-title span { + color: #4f46e5; +} + +.portal-logo-subtitle { + margin-top: 0.2rem; + color: #14b8a6; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.portal-nav-label { + margin: 0 0 1rem; + color: #94a3b8; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.portal-navigation { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-nav-link { + padding: 0.75rem 1rem; + border-left: 4px solid transparent; + border-radius: 0 0.75rem 0.75rem 0; + color: #64748b; + font-size: 0.95rem; + font-weight: 600; + text-decoration: none; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.portal-nav-link:hover { + background: #eef2f7; + color: #334155; +} + +.portal-nav-link-active { + background: #e7eafe; + border-left-color: #4f46e5; + color: #4338ca; +} + +.portal-sidebar-footer { + padding: 1.5rem; +} + +.portal-logout { + color: #94a3b8; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; +} + +.portal-logout:hover { + color: #64748b; +} + +.portal-workspace { + min-width: 0; + height: 100vh; + flex: 1; display: flex; flex-direction: column; - overflow-y: auto; -} -.rh-sidebar-inner { padding: 24px 24px 0; flex: 1; } - -.rh-logo { display:flex; align-items:center; gap:12px; margin-bottom:32px; } -.rh-logo-icon { width:40px; height:40px; background:#4f46e5; border-radius:8px; display:flex; align-items:center; justify-content:center; color:white; font-weight:700; font-size:1.25rem; } -.rh-logo-name { font-weight:700; font-size:1.1rem; color:#0f172a; line-height:1; } -.rh-logo-accent { color:#4f46e5; } -.rh-logo-sub { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.1em; color:#2dd4bf; margin-top:2px; } - -.rh-nav-label { font-size:12px; font-weight:500; color:#94a3b8; text-transform:uppercase; letter-spacing:.1em; margin-bottom:16px; } -.rh-nav { display:flex; flex-direction:column; gap:4px; } - -.rh-nav-btn { - display:flex; align-items:center; width:100%; - padding:12px 16px; font-weight:500; font-size:15px; - border-radius:0 12px 12px 0; border-left:4px solid transparent; - color:#64748b; cursor:pointer; transition:all .15s; - background:transparent; text-align:left; - border-top:none; border-right:none; border-bottom:none; -} -.rh-nav-btn:hover { background:#f8fafc; } -.rh-nav-active { background:#eef2ff !important; color:#4338ca !important; border-left-color:#4f46e5 !important; font-weight:600 !important; } - -.rh-sidebar-footer { padding:24px; } -.rh-back-link { font-size:14px; color:#94a3b8; font-weight:500; text-decoration:none; } -.rh-back-link:hover { color:#64748b; } - -/* ── Main ── */ -.rh-main { flex:1; overflow-y:auto; padding:24px 32px; } -.rh-tab-content { max-width:56rem; margin:0 auto; } - -/* ── Spacing helpers ── */ -.rh-space-y-3 > * + * { margin-top:12px; } -.rh-space-y-4 > * + * { margin-top:16px; } -.rh-space-y-5 > * + * { margin-top:20px; } -.rh-space-y-6 > * + * { margin-top:24px; } - -/* ── Cards ── */ -.rh-card { - background:white; padding:20px; border-radius:8px; - border:1px solid #e2e8f0; box-shadow:0 1px 3px rgba(0,0,0,.06); - display:flex; flex-direction:column; gap:16px; -} - -/* ── Buttons ── */ -.rh-btn-ghost { padding:12px 24px; border-radius:8px; font-weight:500; background:white; border:2px solid #e5e7eb; color:#6b7280; cursor:pointer; transition:background .15s; } -.rh-btn-ghost:hover { background:#f9fafb; } -.rh-btn-indigo { padding:12px 24px; border-radius:8px; font-weight:500; background:#4f46e5; color:white; border:none; cursor:pointer; transition:background .15s; box-shadow:0 4px 14px rgba(99,102,241,.3); } -.rh-btn-indigo:hover { background:#4338ca; } -.rh-btn-indigo:active { transform:scale(.97); } -.rh-btn-dark { padding:16px 32px; border-radius:8px; font-weight:700; font-size:1.1rem; background:#111827; color:white; border:none; cursor:pointer; } -.rh-btn-dark:hover { background:black; } - -.rh-btn-sm { padding:8px 16px; border-radius:8px; font-size:14px; font-weight:500; border:none; cursor:pointer; } -.rh-btn-sm-indigo { padding:8px 16px; border-radius:8px; font-size:14px; font-weight:500; border:none; cursor:pointer; background:#eef2ff; color:#4f46e5; } -.rh-btn-sm-indigo:hover { background:#e0e7ff; } -.rh-btn-sm-danger { background:white; color:#6b7280; } -.rh-btn-sm-danger:hover { background:#fef2f2; color:#ef4444; } -.rh-btn-sm-success { background:#f0fdf4; color:#16a34a; } -.rh-btn-sm-success:hover { background:#dcfce7; } - -.rh-flex-gap { display:flex; gap:8px; } - -/* ── Creation bar ── */ -.rh-creation-bar { display:flex; justify-content:space-between; align-items:center; background:#eef2ff; padding:24px; border-radius:8px; border:1px solid #e0e7ff; font-style:italic; } -.rh-creation-title { font-size:1.25rem; font-weight:500; color:#3730a3; } - -/* ── Week row ── */ -.rh-week-row { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:16px; background:rgba(238,242,255,.5); padding:16px; border-radius:8px; border:1px solid rgba(224,231,255,.5); } -.rh-week-title { font-size:1.1rem; font-weight:500; color:#1e1b4b; } -.rh-tiny-label { font-size:10px; font-weight:500; color:#94a3b8; text-transform:uppercase; letter-spacing:.05em; } - -/* ── Inputs ── */ -.rh-input { font-size:16px; font-weight:500; color:#1f2937; border:2px solid #f1f5f9; border-radius:8px; padding:8px 16px; outline:none; transition:border-color .15s; background:white; width:100%; box-sizing:border-box; } -.rh-input:focus { border-color:#6366f1; } -.rh-title-input { font-size:1.5rem; font-weight:500; color:#1f2937; border:none; border-bottom:2px solid #f1f5f9; outline:none; transition:border-color .15s; padding:8px 0; background:transparent; width:100%; } -.rh-title-input:focus { border-bottom-color:#6366f1; } -.rh-field-input { font-weight:500; color:#1f2937; border:2px solid #f8fafc; background:#f8fafc; border-radius:8px; padding:12px 16px; outline:none; transition:all .15s; font-size:16px; width:100%; box-sizing:border-box; } -.rh-field-input:focus { background:white; border-color:#c7d2fe; } - -/* ── Day pills ── */ -.rh-day-pills { display:flex; flex-wrap:wrap; gap:8px; } -.rh-day-pill { padding:8px 16px; border-radius:8px; font-weight:500; font-size:14px; border:2px solid transparent; background:#f8fafc; color:#94a3b8; cursor:pointer; transition:all .15s; } -.rh-day-pill:hover:not(.rh-day-active) { background:#f1f5f9; } -.rh-day-active { background:#e0e7ff !important; color:#4338ca !important; border-color:#a5b4fc !important; box-shadow:inset 0 1px 3px rgba(0,0,0,.1); } - -/* ── Title row ── */ -.rh-title-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:24px; padding-top:16px; border-top:1px solid #f8fafc; } -.rh-preview-date { background:#eef2ff; padding:16px 24px; border-radius:8px; text-align:center; min-width:200px; } - -/* ── Q type buttons ── */ -.rh-q-btns { display:flex; gap:16px; flex-wrap:wrap; } -.rh-q-btn { flex:1; font-weight:500; padding:16px; border-radius:8px; cursor:pointer; min-width:140px; transition:background .15s; } -.rh-q-btn-blue { background:#eff6ff; color:#4f46e5; border:1px dashed #bfdbfe; } -.rh-q-btn-blue:hover { background:#dbeafe; } -.rh-q-btn-gray { background:white; color:#6b7280; border:1px dashed #e5e7eb; } -.rh-q-btn-gray:hover { background:#f9fafb; } -.rh-q-btn-gray2 { background:#f9fafb; color:#4b5563; border:1px dashed #e5e7eb; } -.rh-q-btn-gray2:hover { background:#f3f4f6; } - -/* ── Question card ── */ -.rh-empty { text-align:center; padding:48px 0; color:#9ca3af; border:4px dashed #f3f4f6; border-radius:8px; background:white; } -.rh-q-card { background:white; border:2px solid #f3f4f6; border-radius:8px; padding:24px; position:relative; cursor:move; box-shadow:0 1px 3px rgba(0,0,0,.05); transition:border-color .15s; } -.rh-q-card:hover { border-color:#e0e7ff; } -.rh-q-drag-handle { position:absolute; top:16px; left:16px; color:#d1d5db; } -.rh-q-card:hover .rh-q-drag-handle { color:#818cf8; } -.rh-q-remove { position:absolute; top:16px; right:16px; background:none; border:none; color:#d1d5db; cursor:pointer; font-size:1.1rem; } -.rh-q-remove:hover { color:#ef4444; } - -.rh-q-badge { font-size:12px; font-weight:500; text-transform:uppercase; letter-spacing:.05em; padding:4px 12px; border-radius:9999px; } -.rh-badge-blue { background:#dbeafe; color:#4338ca; } -.rh-badge-green { background:#d1fae5; color:#065f46; } -.rh-badge-red { background:#fee2e2; color:#991b1b; } -.rh-badge-gray { background:#f3f4f6; color:#374151; } - -.rh-grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; } -@media (max-width:768px) { .rh-grid-2 { grid-template-columns:1fr; } } - -/* ── MCQ ── */ -.rh-choice-row { display:flex; align-items:center; gap:12px; } -.rh-choice-letter { width:32px; height:32px; border-radius:9999px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:14px; flex-shrink:0; background:#f3f4f6; color:#6b7280; } -.rh-choice-correct { background:#10b981; color:white; } -.rh-set-correct { padding:8px 12px; border-radius:8px; font-weight:500; font-size:12px; border:none; cursor:pointer; background:#f3f4f6; color:#9ca3af; } -.rh-set-correct:hover { background:#d1fae5; color:#059669; } -.rh-set-correct-active { background:#10b981; color:white; } -.rh-add-choice { width:100%; padding:12px; border:2px dashed #a7f3d0; color:#10b981; font-weight:500; border-radius:8px; background:transparent; cursor:pointer; } -.rh-add-choice:hover { background:#ecfdf5; } - -/* ── History form rows ── */ -.rh-form-row { background:white; padding:20px 24px; border-radius:24px; border:1px solid #e2e8f0; display:flex; align-items:center; gap:20px; transition:all .2s; } -.rh-form-row:hover { border-color:#a5b4fc; box-shadow:0 2px 8px rgba(99,102,241,.08); } -.rh-unpublished { opacity:.6; } -.rh-week-badge { background:#eef2ff; color:#4338ca; padding:12px; border-radius:8px; display:flex; flex-direction:column; align-items:center; min-width:90px; border:1px solid #e0e7ff; flex-shrink:0; font-size:11px; font-weight:500; } -.rh-day-badge { font-size:11px; font-weight:500; text-transform:uppercase; background:#4f46e5; color:white; padding:2px 8px; border-radius:6px; margin-top:4px; } -.rh-form-actions { display:flex; gap:8px; align-items:center; flex-shrink:0; } -.rh-status-pill { font-size:10px; font-weight:500; text-transform:uppercase; letter-spacing:.1em; padding:6px 12px; border-radius:9999px; } -.rh-active { background:#dcfce7; color:#15803d; } -.rh-inactive { background:#f3f4f6; color:#6b7280; } - -/* ── Progress table ── */ -.rh-th { padding:16px; font-size:12px; font-weight:500; color:#9ca3af; text-transform:uppercase; letter-spacing:.05em; } -.rh-tr { border-bottom:1px solid #f3f4f6; transition:background .15s; } -.rh-tr:hover { background:rgba(238,242,255,.5); } -.rh-tr:last-child { border-bottom:none; } -.rh-td { padding:16px; } -.rh-avatar { width:32px; height:32px; border-radius:9999px; background:#e0e7ff; color:#4338ca; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:700; box-shadow:0 1px 3px rgba(0,0,0,.1); flex-shrink:0; } -.rh-badge-pill { display:inline-flex; align-items:center; gap:8px; padding:4px 12px; border-radius:8px; font-weight:500; background:#f3f4f6; color:#374151; } - -/* ── Raffle ── */ -.rh-raffle-card { background:white; border-radius:8px; box-shadow:0 25px 50px rgba(0,0,0,.12); padding:48px; max-width:36rem; margin:0 auto; border-bottom:8px solid #818cf8; } -.rh-spin-btn { background:#4f46e5; color:white; font-size:1.5rem; font-weight:500; padding:20px 64px; border-radius:9999px; border:none; cursor:pointer; box-shadow:0 10px 25px rgba(99,102,241,.3); transition:all .2s; } -.rh-spin-btn:hover { background:#4338ca; } -.rh-spin-btn:active { transform:scale(.95); } -@keyframes rhSpin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } -.rh-spin-emoji { display:inline-block; font-size:6rem; animation:rhSpin .8s linear infinite; margin-bottom:24px; } -@keyframes rhBounce { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-16px); } } -.rh-bounce-emoji { display:inline-block; font-size:6rem; animation:rhBounce 1s ease-in-out infinite; margin-bottom:24px; } - -/* ── Announcements ── */ -.rh-subtab-switch { display:flex; background:#eef2ff; padding:6px; border-radius:8px; border:1px solid #e0e7ff; } -.rh-subtab-btn { padding:10px 24px; border-radius:6px; font-weight:500; border:none; cursor:pointer; transition:all .3s; background:transparent; color:#818cf8; } -.rh-subtab-btn:hover { color:#4f46e5; } -.rh-subtab-active { background:white !important; color:#3730a3 !important; box-shadow:0 2px 8px rgba(0,0,0,.1) !important; } - -.rh-ann-grid { display:grid; grid-template-columns:1fr 1fr; gap:24px; } -@media (max-width:1024px) { .rh-ann-grid { grid-template-columns:1fr; } } - -.rh-btn-post { width:100%; padding:16px; background:#4f46e5; color:white; border:none; border-radius:8px; font-weight:500; font-size:1.2rem; cursor:pointer; box-shadow:0 10px 25px rgba(99,102,241,.25); } -.rh-btn-post:hover { background:#4338ca; } - -.rh-preview-panel { background:rgba(238,242,255,.3); padding:32px; border-radius:8px; border:2px dashed #e0e7ff; display:flex; flex-direction:column; justify-content:center; } -.rh-preview-card { background:white; padding:24px; border-radius:40px; border:1px solid #f3f4f6; box-shadow:0 10px 25px rgba(0,0,0,.08); display:flex; gap:24px; align-items:flex-start; } -.rh-preview-icon { padding:16px; background:#eef2ff; border-radius:16px; font-size:2.5rem; flex-shrink:0; border:1px solid #e0e7ff; display:flex; align-items:center; justify-content:center; min-width:80px; min-height:80px; } - -.rh-ann-item { padding:20px; border-radius:12px; border:2px solid; display:flex; gap:20px; align-items:flex-start; transition:all .2s; } -.rh-ann-active { background:white; border-color:#a5b4fc; } -.rh-ann-active:hover { border-color:#6366f1; } -.rh-ann-expired { background:#f9fafb; border-color:#f3f4f6; opacity:.7; } -.rh-ann-expired:hover { opacity:1; } -.rh-ann-icon { padding:16px; border-radius:12px; font-size:1.875rem; flex-shrink:0; border:1px solid; display:flex; align-items:center; justify-content:center; min-width:80px; min-height:80px; } -.rh-ann-icon-active { background:#eef2ff; border-color:#e0e7ff; } -.rh-ann-icon-expired { background:#f3f4f6; border-color:#e5e7eb; } -.rh-ann-pill { padding:2px 8px; border-radius:9999px; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; } -.rh-ann-pill-green { background:#dcfce7; color:#15803d; border:1px solid #bbf7d0; } -.rh-ann-pill-gray { background:#e5e7eb; color:#6b7280; border:1px solid #d1d5db; } -.rh-ann-pill-neutral { background:#f3f4f6; color:#4b5563; } - -/* ── Modal ── */ -.rh-modal-backdrop { position:fixed; inset:0; z-index:300; display:flex; align-items:center; justify-content:center; padding:24px; } -.rh-modal-overlay { position:absolute; inset:0; background:rgba(15,23,42,.4); backdrop-filter:blur(4px); } -.rh-modal-box { position:relative; background:white; width:100%; max-width:42rem; border-radius:8px; box-shadow:0 25px 50px rgba(0,0,0,.25); display:flex; flex-direction:column; max-height:90vh; border-bottom:8px solid #4f46e5; overflow:hidden; } -.rh-modal-head { padding:24px; background:#eef2ff; border-bottom:1px solid #e0e7ff; display:flex; justify-content:space-between; align-items:center; } -.rh-modal-title { font-size:1.5rem; font-weight:900; color:#111827; } -.rh-modal-meta { font-size:12px; color:#9ca3af; font-weight:700; text-transform:uppercase; letter-spacing:.1em; margin-top:4px; } -.rh-modal-close { width:48px; height:48px; border-radius:8px; background:white; color:#9ca3af; display:flex; align-items:center; justify-content:center; border:none; cursor:pointer; } -.rh-modal-close:hover { color:#111827; transform:scale(1.1); } -.rh-modal-body { padding:24px; overflow-y:auto; display:flex; flex-direction:column; gap:24px; } -.rh-modal-foot { padding:24px; background:#f9fafb; border-top:1px solid #f3f4f6; display:flex; justify-content:flex-end; } -.rh-modal-step { padding:24px; border-radius:8px; border:2px solid #f9fafb; background:rgba(249,250,251,.5); display:flex; flex-direction:column; gap:12px; } -.rh-step-badge { display:inline-block; background:white; padding:4px 12px; border-radius:6px; font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.1em; color:#6366f1; box-shadow:0 1px 3px rgba(0,0,0,.1); width:fit-content; } -.rh-step-text { font-size:1.1rem; font-weight:700; color:#111827; line-height:1.4; } -.rh-step-es { font-size:1rem; color:#6b7280; font-style:italic; font-weight:500; } -.rh-step-ref { padding:16px; background:white; border-radius:6px; border:1px solid #f3f4f6; } -.rh-step-ref-label { font-size:10px; font-weight:900; color:#d1d5db; text-transform:uppercase; letter-spacing:.1em; margin-bottom:4px; } -.rh-step-ref-val { font-size:14px; font-weight:700; color:#6366f1; } -.rh-step-choices { display:grid; grid-template-columns:1fr 1fr; gap:8px; } -.rh-step-choice { padding:12px; border-radius:8px; border:2px solid #f3f4f6; font-size:14px; font-weight:700; background:white; color:#9ca3af; opacity:.6; } -.rh-correct { background:#f0fdf4; border-color:#bbf7d0; color:#15803d; opacity:1; } -.rh-step-url code { font-size:12px; background:#f3f4f6; padding:4px 8px; border-radius:4px; color:#6b7280; word-break:break-all; } - -/* ── Transitions ── */ -.rh-fade-enter-active, .rh-fade-leave-active { transition:opacity .25s ease; } -.rh-fade-enter-from, .rh-fade-leave-to { opacity:0; } \ No newline at end of file + overflow: hidden; + background: #eef2f7; +} + +.portal-header { + min-height: 5.5rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 1rem 2rem; + background: #f8fafc; + border-bottom: 1px solid #dbe3ec; +} + +.portal-header-label { + margin: 0 0 0.2rem; + color: #94a3b8; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.portal-header-title { + margin: 0; + color: #0f172a; + font-size: 1.35rem; + font-weight: 600; +} + +.portal-context { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.portal-context label { + color: #64748b; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.portal-context select { + min-width: 15rem; + padding: 0.7rem 0.9rem; + background: #f8fafc; + border: 1px solid #cbd5e1; + border-radius: 0.65rem; + color: #1e293b; + font: inherit; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; +} + +.portal-context select:focus { + border-color: #4f46e5; + outline: none; + box-shadow: 0 0 0 3px rgb(79 70 229 / 12%); +} + +.portal-main { + width: 100%; + min-width: 0; + flex: 1; + min-height: 0; + display: flex; + overflow: hidden; + padding: 1.5rem 2rem; + background: #eef2f7; +} + +@media (max-width: 800px) { + .portal-layout { + display: block; + overflow-y: auto; + } + + .portal-sidebar { + width: 100%; + height: auto; + } + + .portal-navigation { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + .portal-sidebar-footer { + display: none; + } + + .portal-workspace { + height: auto; + min-height: calc(100vh - 12rem); + } + + .portal-header { + align-items: flex-start; + flex-direction: column; + } + + .portal-context { + width: 100%; + } + + .portal-context select { + flex: 1; + min-width: 0; + } + + .portal-main { + overflow: visible; + padding: 1rem; + } +} diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 24b0275b..172ebbcb 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -1,41 +1,83 @@ - - -