Merge Schedules into Admin, remove standalone ScheduleView
- Move schedule template management into AdminView under a new Schedules section - Remove ScheduleView.vue and its route, drop Schedules link from NavBar - Delete docker-compose.override.yml (dev override no longer needed) - Fix CORS_ORIGINS default to port 8057 in docker-compose.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
# Development overrides — hot reload for backend, Vite dev server for frontend
|
||||
services:
|
||||
backend:
|
||||
volumes:
|
||||
- ./backend/app:/app/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: builder
|
||||
ports:
|
||||
- "8054:5173"
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/index.html:/app/index.html
|
||||
command: npm run dev -- --host 0.0.0.0 --port 5173
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8054}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<RouterLink class="nav-brand" to="/dashboard">🏠 Homeschool</RouterLink>
|
||||
<div class="nav-links">
|
||||
<RouterLink to="/dashboard" active-class="active">Dashboard</RouterLink>
|
||||
<RouterLink to="/schedules" active-class="active">Schedules</RouterLink>
|
||||
<RouterLink to="/logs" active-class="active">Logs</RouterLink>
|
||||
<RouterLink to="/admin" active-class="active">Admin</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,6 @@ const routes = [
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/schedules',
|
||||
name: 'schedules',
|
||||
component: () => import('@/views/ScheduleView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
|
||||
@@ -84,6 +84,86 @@
|
||||
<div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Schedules section -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Schedules</h2>
|
||||
<button class="btn-primary btn-sm" @click="showCreateForm = !showCreateForm">+ New Template</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div class="card" v-if="showCreateForm">
|
||||
<h3>New Schedule Template</h3>
|
||||
<form @submit.prevent="createTemplate">
|
||||
<div class="field">
|
||||
<label>Template Name</label>
|
||||
<input v-model="newTemplate.name" placeholder="e.g. Monday Schedule" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Child (optional — leave blank for all children)</label>
|
||||
<select v-model="newTemplate.child_id">
|
||||
<option :value="null">All children</option>
|
||||
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-sm" @click="showCreateForm = false">Cancel</button>
|
||||
<button type="submit" class="btn-primary btn-sm">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Template list -->
|
||||
<div class="template-list">
|
||||
<div v-for="template in templates" :key="template.id" class="template-card">
|
||||
<div class="template-header">
|
||||
<div>
|
||||
<div class="template-name">{{ template.name }}</div>
|
||||
<div class="template-child">
|
||||
{{ template.child_id ? childName(template.child_id) : 'All children' }}
|
||||
· {{ template.blocks.length }} blocks
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-actions">
|
||||
<button class="btn-sm" @click="editingTemplate = editingTemplate === template.id ? null : template.id">
|
||||
{{ editingTemplate === template.id ? 'Close' : 'Edit Blocks' }}
|
||||
</button>
|
||||
<button class="btn-sm btn-danger" @click="deleteTemplate(template.id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block editor -->
|
||||
<div v-if="editingTemplate === template.id" class="block-editor">
|
||||
<div class="block-list">
|
||||
<div v-for="block in template.blocks" :key="block.id" class="block-row">
|
||||
<span class="block-time">{{ block.time_start }} – {{ block.time_end }}</span>
|
||||
<span class="block-label">{{ block.label || subjectName(block.subject_id) || 'Unnamed' }}</span>
|
||||
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="template.blocks.length === 0" class="empty-small">No blocks yet.</div>
|
||||
</div>
|
||||
|
||||
<!-- Add block form -->
|
||||
<form @submit.prevent="addBlock(template.id)" class="add-block-form">
|
||||
<select v-model="newBlock.subject_id">
|
||||
<option :value="null">No subject</option>
|
||||
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
|
||||
</select>
|
||||
<input v-model="newBlock.time_start" type="time" required />
|
||||
<span>to</span>
|
||||
<input v-model="newBlock.time_end" type="time" required />
|
||||
<input v-model="newBlock.label" placeholder="Label (optional)" />
|
||||
<button type="submit" class="btn-primary btn-sm">Add Block</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templates.length === 0 && !showCreateForm" class="empty-small">
|
||||
No schedule templates yet. Create one to get started.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,12 +177,10 @@ import NavBar from '@/components/NavBar.vue'
|
||||
const childrenStore = useChildrenStore()
|
||||
const subjects = ref([])
|
||||
|
||||
// Children
|
||||
const showChildForm = ref(false)
|
||||
const showSubjectForm = ref(false)
|
||||
const newChild = ref({ name: '', color: '#4F46E5' })
|
||||
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
|
||||
const editingChild = ref(null)
|
||||
const editingSubject = ref(null)
|
||||
|
||||
async function createChild() {
|
||||
await childrenStore.createChild(newChild.value)
|
||||
@@ -133,6 +211,11 @@ async function deleteChild(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Subjects
|
||||
const showSubjectForm = ref(false)
|
||||
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
|
||||
const editingSubject = ref(null)
|
||||
|
||||
async function loadSubjects() {
|
||||
const res = await api.get('/api/subjects')
|
||||
subjects.value = res.data
|
||||
@@ -168,15 +251,64 @@ async function deleteSubject(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Schedules
|
||||
const templates = ref([])
|
||||
const showCreateForm = ref(false)
|
||||
const editingTemplate = ref(null)
|
||||
const newTemplate = ref({ name: '', child_id: null, is_default: false })
|
||||
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 })
|
||||
|
||||
function childName(id) {
|
||||
return childrenStore.children.find((c) => c.id === id)?.name || 'Unknown'
|
||||
}
|
||||
|
||||
function subjectName(id) {
|
||||
return subjects.value.find((s) => s.id === id)?.name || null
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const res = await api.get('/api/schedules')
|
||||
templates.value = res.data
|
||||
}
|
||||
|
||||
async function createTemplate() {
|
||||
await api.post('/api/schedules', newTemplate.value)
|
||||
newTemplate.value = { name: '', child_id: null, is_default: false }
|
||||
showCreateForm.value = false
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
async function deleteTemplate(id) {
|
||||
if (confirm('Delete this template and all its blocks?')) {
|
||||
await api.delete(`/api/schedules/${id}`)
|
||||
await loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
async function addBlock(templateId) {
|
||||
const payload = {
|
||||
...newBlock.value,
|
||||
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
|
||||
}
|
||||
await api.post(`/api/schedules/${templateId}/blocks`, payload)
|
||||
newBlock.value = { subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 }
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
async function deleteBlock(templateId, blockId) {
|
||||
await api.delete(`/api/schedules/${templateId}/blocks/${blockId}`)
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await childrenStore.fetchChildren()
|
||||
await loadSubjects()
|
||||
await Promise.all([loadSubjects(), loadTemplates()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; background: #0f172a; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 2rem; }
|
||||
|
||||
.section { margin-bottom: 3rem; }
|
||||
@@ -223,8 +355,6 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
.item-meta { font-size: 0.8rem; color: #64748b; }
|
||||
.item-actions { display: flex; gap: 0.4rem; }
|
||||
|
||||
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||
|
||||
.edit-input {
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: #0f172a;
|
||||
@@ -236,6 +366,58 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Schedule styles */
|
||||
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
.card h3 { margin-bottom: 1.25rem; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
|
||||
.template-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.template-card { background: #1e293b; border-radius: 1rem; padding: 1.25rem; }
|
||||
.template-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.template-name { font-size: 1.05rem; font-weight: 600; }
|
||||
.template-child { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
|
||||
.template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
|
||||
.block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; }
|
||||
.block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
.block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; }
|
||||
.block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }
|
||||
.block-label { flex: 1; font-size: 0.9rem; }
|
||||
|
||||
.add-block-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
background: #0f172a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.add-block-form select,
|
||||
.add-block-form input {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.4rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.add-block-form span { color: #64748b; }
|
||||
|
||||
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #4f46e5;
|
||||
@@ -261,4 +443,5 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
.btn-sm:hover { background: #334155; }
|
||||
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
|
||||
.btn-sm.btn-danger:hover { background: #7f1d1d; }
|
||||
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<NavBar />
|
||||
<main class="container">
|
||||
<div class="page-header">
|
||||
<h1>Schedules</h1>
|
||||
<button class="btn-primary" @click="showCreateForm = !showCreateForm">+ New Template</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div class="card" v-if="showCreateForm">
|
||||
<h3>New Schedule Template</h3>
|
||||
<form @submit.prevent="createTemplate">
|
||||
<div class="field">
|
||||
<label>Template Name</label>
|
||||
<input v-model="newTemplate.name" placeholder="e.g. Monday Schedule" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Child (optional — leave blank for all children)</label>
|
||||
<select v-model="newTemplate.child_id">
|
||||
<option :value="null">All children</option>
|
||||
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-sm" @click="showCreateForm = false">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Template list -->
|
||||
<div class="template-list">
|
||||
<div v-for="template in templates" :key="template.id" class="template-card">
|
||||
<div class="template-header">
|
||||
<div>
|
||||
<div class="template-name">{{ template.name }}</div>
|
||||
<div class="template-child">
|
||||
{{ template.child_id ? childName(template.child_id) : 'All children' }}
|
||||
· {{ template.blocks.length }} blocks
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-actions">
|
||||
<button class="btn-sm" @click="editingTemplate = editingTemplate === template.id ? null : template.id">
|
||||
{{ editingTemplate === template.id ? 'Close' : 'Edit Blocks' }}
|
||||
</button>
|
||||
<button class="btn-sm btn-danger" @click="deleteTemplate(template.id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block editor -->
|
||||
<div v-if="editingTemplate === template.id" class="block-editor">
|
||||
<div class="block-list">
|
||||
<div v-for="block in template.blocks" :key="block.id" class="block-row">
|
||||
<span class="block-time">{{ block.time_start }} – {{ block.time_end }}</span>
|
||||
<span class="block-label">{{ block.label || subjectName(block.subject_id) || 'Unnamed' }}</span>
|
||||
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="template.blocks.length === 0" class="empty-small">No blocks yet.</div>
|
||||
</div>
|
||||
|
||||
<!-- Add block form -->
|
||||
<form @submit.prevent="addBlock(template.id)" class="add-block-form">
|
||||
<select v-model="newBlock.subject_id">
|
||||
<option :value="null">No subject</option>
|
||||
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
|
||||
</select>
|
||||
<input v-model="newBlock.time_start" type="time" required />
|
||||
<span>to</span>
|
||||
<input v-model="newBlock.time_end" type="time" required />
|
||||
<input v-model="newBlock.label" placeholder="Label (optional)" />
|
||||
<button type="submit" class="btn-primary btn-sm">Add Block</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templates.length === 0 && !showCreateForm" class="empty-state">
|
||||
No schedule templates yet. Create one to get started.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useChildrenStore } from '@/stores/children'
|
||||
import api from '@/composables/useApi'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
|
||||
const childrenStore = useChildrenStore()
|
||||
const templates = ref([])
|
||||
const subjects = ref([])
|
||||
const showCreateForm = ref(false)
|
||||
const editingTemplate = ref(null)
|
||||
|
||||
const newTemplate = ref({ name: '', child_id: null, is_default: false })
|
||||
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 })
|
||||
|
||||
function childName(id) {
|
||||
return childrenStore.children.find((c) => c.id === id)?.name || 'Unknown'
|
||||
}
|
||||
|
||||
function subjectName(id) {
|
||||
return subjects.value.find((s) => s.id === id)?.name || null
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const res = await api.get('/api/schedules')
|
||||
templates.value = res.data
|
||||
}
|
||||
|
||||
async function createTemplate() {
|
||||
await api.post('/api/schedules', newTemplate.value)
|
||||
newTemplate.value = { name: '', child_id: null, is_default: false }
|
||||
showCreateForm.value = false
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
async function deleteTemplate(id) {
|
||||
if (confirm('Delete this template and all its blocks?')) {
|
||||
await api.delete(`/api/schedules/${id}`)
|
||||
await loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
async function addBlock(templateId) {
|
||||
const payload = {
|
||||
...newBlock.value,
|
||||
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
|
||||
}
|
||||
await api.post(`/api/schedules/${templateId}/blocks`, payload)
|
||||
newBlock.value = { subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 }
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
async function deleteBlock(templateId, blockId) {
|
||||
await api.delete(`/api/schedules/${templateId}/blocks/${blockId}`)
|
||||
await loadTemplates()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await childrenStore.fetchChildren()
|
||||
const [sRes] = await Promise.all([api.get('/api/subjects'), loadTemplates()])
|
||||
subjects.value = sRes.data
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; background: #0f172a; }
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
|
||||
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
.card h3 { margin-bottom: 1.25rem; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
|
||||
.template-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.template-card { background: #1e293b; border-radius: 1rem; padding: 1.25rem; }
|
||||
.template-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.template-name { font-size: 1.05rem; font-weight: 600; }
|
||||
.template-child { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
|
||||
.template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
|
||||
.block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; }
|
||||
.block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
.block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; }
|
||||
.block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }
|
||||
.block-label { flex: 1; font-size: 0.9rem; }
|
||||
|
||||
.add-block-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
background: #0f172a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.add-block-form select,
|
||||
.add-block-form input {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.4rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.add-block-form span { color: #64748b; }
|
||||
|
||||
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
|
||||
.empty-small { color: #64748b; font-size: 0.85rem; padding: 0.5rem 0; }
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary:hover { background: #4338ca; }
|
||||
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-sm:hover { background: #334155; }
|
||||
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
|
||||
.btn-sm.btn-danger:hover { background: #7f1d1d; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user