refactor: натравил форматтер на весь фронт

This commit is contained in:
2026-05-25 02:06:11 +03:00
parent 24df65a13c
commit 98aaa86ec4
43 changed files with 1947 additions and 657 deletions
+173 -47
View File
@@ -21,7 +21,9 @@ const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false)
const filtersOpen = ref(false)
const enrollmentLimitModalOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
onMounted(() => {
if (!lecturesStore.all.length) void lecturesStore.fetchLectures()
@@ -48,28 +50,44 @@ const directions = [
'Экономика и маркетинг',
]
const teachers = computed(() => ['Все преподаватели', ...new Set(lecturesStore.all.map(l => l.teacher))])
const buildings = computed(() => ['Все корпуса', ...new Set(lecturesStore.all.map(l => l.building))])
const teachers = computed(() => [
'Все преподаватели',
...new Set(lecturesStore.all.map((l) => l.teacher)),
])
const buildings = computed(() => [
'Все корпуса',
...new Set(lecturesStore.all.map((l) => l.building)),
])
function toggleTag(value: string) {
const target = tagFilters.value.find(t => t.value === value)
const target = tagFilters.value.find((t) => t.value === value)
if (target) target.active = !target.active
}
const activeTags = computed(() => tagFilters.value.filter(t => t.active).map(t => t.value))
const activeTags = computed(() => tagFilters.value.filter((t) => t.active).map((t) => t.value))
const filtered = computed(() =>
lecturesStore.all.filter(l => {
lecturesStore.all.filter((l) => {
const matchesSearch = l.title.toLowerCase().includes(search.value.toLowerCase())
const directionKey = direction.value.split(' ')[0] || ''
const matchesDirection = direction.value === 'Все направления' || l.institute.includes(directionKey)
const matchesDirection =
direction.value === 'Все направления' || l.institute.includes(directionKey)
const matchesTeacher = teacher.value === 'Все преподаватели' || l.teacher === teacher.value
const matchesBuilding = building.value === 'Все корпуса' || l.building === building.value
const matchesFormat = format.value === 'all' || l.format === format.value
const matchesTags = activeTags.value.length === 0 || activeTags.value.some(tag => l.tags.includes(tag))
const matchesTags =
activeTags.value.length === 0 || activeTags.value.some((tag) => l.tags.includes(tag))
const matchesFree = !onlyFree.value || l.freeSeats > 0
return matchesSearch && matchesDirection && matchesTeacher && matchesBuilding && matchesFormat && matchesTags && matchesFree
})
return (
matchesSearch &&
matchesDirection &&
matchesTeacher &&
matchesBuilding &&
matchesFormat &&
matchesTags &&
matchesFree
)
}),
)
const appliedFilters = computed(() => {
@@ -95,7 +113,7 @@ const tableColumns = [
const calendarGroups = computed(() => {
const groups: Record<string, typeof filtered.value> = {}
filtered.value.forEach(l => {
filtered.value.forEach((l) => {
const date = new Date(l.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
groups[date] = groups[date] || []
groups[date].push(l)
@@ -130,7 +148,9 @@ function isRegistered(id: string) {
<div class="catalog-header">
<div>
<h1 class="page-title">Каталог открытых лекций</h1>
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p>
<p class="text-secondary">
Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.
</p>
</div>
<div class="header-actions">
<SearchInput v-model="search" placeholder="Поиск по теме лекции" />
@@ -171,8 +191,12 @@ function isRegistered(id: string) {
<label class="filter-label">Формат</label>
<div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">
Офлайн
</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">
Онлайн
</button>
</div>
</div>
<div>
@@ -191,9 +215,13 @@ function isRegistered(id: string) {
<div class="view-row">
<div class="segmented">
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">Карточки</button>
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">
Карточки
</button>
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">Список</button>
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">Календарь</button>
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">
Календарь
</button>
</div>
<div class="applied" v-if="appliedFilters.length">
<span class="text-secondary">Фильтры:</span>
@@ -210,7 +238,10 @@ function isRegistered(id: string) {
</div>
<div v-else-if="filtered.length === 0">
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
<EmptyState
title="Нет результатов"
subtitle="Попробуйте изменить фильтры или сбросить поиск."
/>
</div>
<div v-else-if="viewMode === 'cards'" class="cards-grid">
@@ -239,7 +270,9 @@ function isRegistered(id: string) {
</template>
<template #seats="{ row }">
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
{{ row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}` }}
{{
row.registrationClosed ? 'Запись закрыта' : `${row.enrolledSeats}/${row.totalSeats}`
}}
</span>
</template>
<template #action="{ row }">
@@ -256,12 +289,14 @@ function isRegistered(id: string) {
</div>
<div v-else class="calendar-view">
<GlassCard v-for="([date, items]) in calendarGroups" :key="date" class="calendar-day">
<GlassCard v-for="[date, items] in calendarGroups" :key="date" class="calendar-day">
<div class="calendar-date">{{ date }}</div>
<div class="calendar-items">
<div v-for="l in items" :key="l.id" class="calendar-item">
<div class="calendar-title">{{ l.title }}</div>
<div class="calendar-meta">{{ l.time }} {{ l.building }} {{ l.room ? `ауд. ${l.room}` : '' }}</div>
<div class="calendar-meta">
{{ l.time }} {{ l.building }} {{ l.room ? `ауд. ${l.room}` : '' }}
</div>
<button class="btn-secondary btn-sm">Подробнее</button>
</div>
</div>
@@ -292,8 +327,12 @@ function isRegistered(id: string) {
<label>Формат</label>
<div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">
Офлайн
</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">
Онлайн
</button>
</div>
<label>Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
@@ -313,7 +352,11 @@ function isRegistered(id: string) {
</template>
<style scoped>
.catalog { display: flex; flex-direction: column; gap: 20px; }
.catalog {
display: flex;
flex-direction: column;
gap: 20px;
}
.catalog-header {
display: flex;
align-items: center;
@@ -321,14 +364,24 @@ function isRegistered(id: string) {
gap: 16px;
flex-wrap: wrap;
}
.header-actions { display: flex; gap: 12px; align-items: center; flex: 1; justify-content: flex-end; }
.filters-btn { display: none; }
.header-actions {
display: flex;
gap: 12px;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.filters-btn {
display: none;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.filters-grid > * { min-width: 0; }
.filters-grid > * {
min-width: 0;
}
.filter-label {
font-size: 12px;
font-weight: 600;
@@ -343,7 +396,7 @@ function isRegistered(id: string) {
overflow: hidden;
}
.segmented button {
background: rgba(255,255,255,0.7);
background: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 14px;
font-size: 13px;
@@ -351,35 +404,108 @@ function isRegistered(id: string) {
color: var(--color-text-secondary);
}
.segmented button.active {
background: rgba(34,197,94,0.15);
background: rgba(34, 197, 94, 0.15);
color: var(--color-primary-dark);
font-weight: 600;
}
.free-toggle { display: flex; flex-direction: column; gap: 6px; }
.switch { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--color-text-secondary); }
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.applied { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.applied .tag-chip { max-width: 100%; min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.25; }
.free-toggle {
display: flex;
flex-direction: column;
gap: 6px;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-secondary);
}
.view-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.applied {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.applied .tag-chip {
max-width: 100%;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.25;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.list-title { font-weight: 600; }
.list-view { margin-top: 6px; }
.calendar-view { display: flex; flex-direction: column; gap: 14px; }
.calendar-day { padding: 16px; }
.calendar-date { font-weight: 700; margin-bottom: 8px; }
.calendar-items { display: flex; flex-direction: column; gap: 10px; }
.calendar-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--color-border-glass); padding-bottom: 8px; }
.calendar-item:last-child { border-bottom: none; padding-bottom: 0; }
.calendar-title { font-weight: 600; }
.calendar-meta { font-size: 12px; color: var(--color-text-secondary); }
.modal-filters { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.list-title {
font-weight: 600;
}
.list-view {
margin-top: 6px;
}
.calendar-view {
display: flex;
flex-direction: column;
gap: 14px;
}
.calendar-day {
padding: 16px;
}
.calendar-date {
font-weight: 700;
margin-bottom: 8px;
}
.calendar-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 8px;
}
.calendar-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.calendar-title {
font-weight: 600;
}
.calendar-meta {
font-size: 12px;
color: var(--color-text-secondary);
}
.modal-filters {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
@media (max-width: 768px) {
.filters-grid { display: none; }
.filters-btn { display: inline-flex; }
.header-actions { width: 100%; justify-content: space-between; }
.filters-grid {
display: none;
}
.filters-btn {
display: inline-flex;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
}
</style>
+139 -36
View File
@@ -18,7 +18,9 @@ const lectures = useLecturesStore()
const userStore = useUserStore()
const router = useRouter()
const enrollmentLimitModalOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const user = computed(() => auth.user!)
@@ -26,21 +28,26 @@ const userMetaLine = computed(() => {
const parts: string[] = []
if (user.value.institute) parts.push(user.value.institute)
if (user.value.direction) parts.push(user.value.direction)
if (Number.isFinite(user.value.year) && (user.value.year as number) > 0) parts.push(`${user.value.year} курс`)
if (Number.isFinite(user.value.year) && (user.value.year as number) > 0)
parts.push(`${user.value.year} курс`)
return parts.join(' · ')
})
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0])
const recommended = computed(() =>
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
lectures.all.filter((l) => !lectures.registeredIds.includes(l.id)).slice(0, 3),
)
const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3))
const achievements = computed(() => userStore.achievements.filter((a) => a.unlocked).slice(0, 3))
const reminders = computed(() => userStore.notifications.slice(0, 3))
const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const hasNextLevel = computed(
() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
@@ -49,10 +56,14 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Прогресс до уровня ${user.value.level + 1}` : 'Максимальный уровень'
: hasNextLevel.value
? `Прогресс до уровня ${user.value.level + 1}`
: 'Максимальный уровень',
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
)
onMounted(async () => {
@@ -81,9 +92,7 @@ async function registerLecture(id: string) {
<div class="dashboard page-content">
<div class="dashboard-welcome">
<div>
<h1 class="page-title">
Добрый день, {{ formatUserName(user.name) }}!
</h1>
<h1 class="page-title">Добрый день, {{ formatUserName(user.name) }}!</h1>
<p v-if="userMetaLine" class="text-secondary">{{ userMetaLine }}</p>
</div>
<div class="quick-actions">
@@ -113,12 +122,18 @@ async function registerLecture(id: string) {
</div>
</div>
<div class="next-actions">
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button>
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть
</button>
<button class="btn-secondary">Добавить в календарь</button>
</div>
</div>
</GlassCard>
<EmptyState v-else-if="!lectures.loading" title="Пока нет лекций" subtitle="Каталог пуст или данные ещё не синхронизированы." />
<EmptyState
v-else-if="!lectures.loading"
title="Пока нет лекций"
subtitle="Каталог пуст или данные ещё не синхронизированы."
/>
<GlassCard>
<div class="xp-section">
@@ -140,7 +155,11 @@ async function registerLecture(id: string) {
</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div>
<EmptyState v-if="lectures.loading" title="Загружаем рекомендации" subtitle="Получаем данные с backend." />
<EmptyState
v-if="lectures.loading"
title="Загружаем рекомендации"
subtitle="Получаем данные с backend."
/>
<div v-else class="cards-grid">
<LectureCard
v-for="l in recommended"
@@ -195,7 +214,11 @@ async function registerLecture(id: string) {
</template>
<style scoped>
.dashboard { display: flex; flex-direction: column; gap: 24px; }
.dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.dashboard-welcome {
display: flex;
align-items: flex-start;
@@ -203,25 +226,78 @@ async function registerLecture(id: string) {
gap: 16px;
flex-wrap: wrap;
}
.quick-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.next-lecture { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.next-title { font-size: 18px; font-weight: 700; margin: 6px 0; }
.next-meta { display: flex; flex-direction: column; gap: 4px; color: var(--color-text-secondary); font-size: 13px; }
.meta-line { display: inline-flex; align-items: center; gap: 6px; }
.meta-icon { color: var(--color-text-secondary); }
.next-actions { display: flex; gap: 10px; align-items: flex-start; }
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.next-lecture {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.next-title {
font-size: 18px;
font-weight: 700;
margin: 6px 0;
}
.next-meta {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--color-text-secondary);
font-size: 13px;
}
.meta-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-icon {
color: var(--color-text-secondary);
}
.next-actions {
display: flex;
gap: 10px;
align-items: flex-start;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.xp-section { display: flex; flex-direction: column; gap: 10px; }
.xp-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; }
.xp-val { color: var(--color-text-secondary); }
.section-header { display: flex; align-items: center; justify-content: space-between; }
.title-with-icon { display: inline-flex; align-items: center; gap: 8px; }
.title-icon { color: var(--color-text); }
.inline-icon { color: var(--color-text); vertical-align: middle; }
.xp-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.xp-header {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.xp-val {
color: var(--color-text-secondary);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-icon {
color: var(--color-text);
}
.inline-icon {
color: var(--color-text);
vertical-align: middle;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -236,15 +312,42 @@ async function registerLecture(id: string) {
font-size: 14px;
cursor: pointer;
}
.two-column { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.reminders { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.two-column {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.achievements {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.reminders {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 10px;
}
.reminder-item {
border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 10px;
}
.reminder-item:last-child { border-bottom: none; padding-bottom: 0; }
.reminder-title { font-weight: 700; margin-bottom: 4px; }
.reminder-body { font-size: 13px; color: var(--color-text-secondary); }
.reminder-date { font-size: 11px; color: var(--color-text-secondary); margin-top: 4px; }
.reminder-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.reminder-title {
font-weight: 700;
margin-bottom: 4px;
}
.reminder-body {
font-size: 13px;
color: var(--color-text-secondary);
}
.reminder-date {
font-size: 11px;
color: var(--color-text-secondary);
margin-top: 4px;
}
</style>
@@ -13,16 +13,24 @@ const route = useRoute()
const router = useRouter()
const lecturesStore = useLecturesStore()
const userStore = useUserStore()
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const addToast = inject('addToast') as
| ((message: string, type?: 'success' | 'error' | 'info') => void)
| undefined
const enrollmentLimitModalOpen = ref(false)
const lectureId = computed(() => String(route.params.id))
const lecture = computed(() => lecturesStore.all.find(l => l.id === lectureId.value))
const isRegistered = computed(() => (lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false))
const slotRegistrationDisabled = computed(() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value)
const lecture = computed(() => lecturesStore.all.find((l) => l.id === lectureId.value))
const isRegistered = computed(() =>
lecture.value ? lecturesStore.isRegistered(lecture.value.id) : false,
)
const slotRegistrationDisabled = computed(
() => !userStore.hasEnrollmentSlotAvailable && !isRegistered.value,
)
const isAttended = computed(() => lecture.value?.status === 'completed')
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lectureId.value).slice(0, 3))
const similarLectures = computed(() =>
lecturesStore.all.filter((l) => l.id !== lectureId.value).slice(0, 3),
)
onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -57,7 +65,10 @@ async function registerLecture() {
</div>
<div v-else-if="!lecture" class="lecture-detail page-content">
<EmptyState title="Лекция не найдена" :subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'" />
<EmptyState
title="Лекция не найдена"
:subtitle="lecturesStore.error ?? 'Попробуйте открыть каталог и выбрать лекцию заново.'"
/>
</div>
<div v-else class="lecture-detail page-content">
@@ -76,9 +87,13 @@ async function registerLecture() {
>
Записаться
</button>
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">Отменить запись</button>
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
Отменить запись
</button>
<button class="btn-secondary">Добавить в календарь</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">Оставить отзыв</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв
</button>
</div>
</div>
@@ -86,19 +101,34 @@ async function registerLecture() {
<GlassCard>
<div class="info-section">
<h3>Преподаватель</h3>
<div class="info-value">{{ lecture.teacher }}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span></div>
<div class="info-sub">{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}</div>
<div class="info-value">
{{ lecture.teacher
}}<span v-if="lecture.teacherTitle"> {{ lecture.teacherTitle }}</span>
</div>
<div class="info-sub">
{{ [lecture.department, lecture.institute].filter(Boolean).join(', ') }}
</div>
</div>
<div class="info-section">
<h3>Детали занятия</h3>
<div class="info-value">{{ new Date(lecture.date).toLocaleDateString('ru-RU') }} {{ lecture.time }}</div>
<div class="info-value">
{{ new Date(lecture.date).toLocaleDateString('ru-RU') }} {{ lecture.time }}
</div>
<div class="info-sub">Длительность: {{ lecture.duration }} мин</div>
<div class="info-sub">Локация: {{ lecture.building }} {{ lecture.room ? `ауд. ${lecture.room}` : '' }}</div>
<div class="info-sub">
Локация: {{ lecture.building }} {{ lecture.room ? `ауд. ${lecture.room}` : '' }}
</div>
</div>
<div class="info-section">
<h3>Места</h3>
<div class="info-value">Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}</div>
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
<div class="info-value">
Записано {{ lecture.enrolledSeats }} из {{ lecture.totalSeats }}
</div>
<StatusBadge
:status="
lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'
"
/>
</div>
<div class="info-section">
<h3>Теги</h3>
@@ -121,8 +151,16 @@ async function registerLecture() {
</template>
<style scoped>
.lecture-detail { display: flex; flex-direction: column; gap: 24px; }
.breadcrumb { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.lecture-detail {
display: flex;
flex-direction: column;
gap: 24px;
}
.breadcrumb {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.header {
display: flex;
align-items: flex-start;
@@ -130,14 +168,39 @@ async function registerLecture() {
gap: 16px;
flex-wrap: wrap;
}
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.info-section { margin-bottom: 16px; }
.info-section:last-child { margin-bottom: 0; }
.info-section h3 { font-size: 14px; margin-bottom: 8px; }
.info-value { font-weight: 700; }
.info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.info-section {
margin-bottom: 16px;
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h3 {
font-size: 14px;
margin-bottom: 8px;
}
.info-value {
font-weight: 700;
}
.info-sub {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+93 -24
View File
@@ -16,10 +16,10 @@ const cancelModal = ref(false)
const selectedId = ref<string | null>(null)
const upcoming = computed(() =>
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
)
const history = computed(() => lecturesStore.all.filter(l => l.status === 'completed'))
const history = computed(() => lecturesStore.all.filter((l) => l.status === 'completed'))
onMounted(async () => {
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
@@ -42,23 +42,37 @@ async function confirmCancel() {
<div class="header">
<div>
<h1 class="page-title">Мои записи</h1>
<p class="text-secondary">Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.</p>
<p class="text-secondary">
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p>
</div>
<button class="btn-secondary">Экспорт в календарь</button>
</div>
<div class="tabs">
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">Предстоящие</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">История</button>
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">
Предстоящие
</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
История
</button>
</div>
<div v-if="activeTab === 'upcoming'" class="list">
<EmptyState v-if="!upcoming.length" title="У вас нет предстоящих лекций" subtitle="Выберите лекцию в каталоге и запишитесь на неё." />
<EmptyState
v-if="!upcoming.length"
title="У вас нет предстоящих лекций"
subtitle="Выберите лекцию в каталоге и запишитесь на неё."
/>
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div>
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div>
<div class="lecture-meta">
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div>
<div class="lecture-actions">
<StatusBadge status="registered" />
@@ -69,12 +83,20 @@ async function confirmCancel() {
</div>
<div v-else class="list">
<EmptyState v-if="!history.length" title="История пока пуста" subtitle="Завершённые лекции появятся здесь после посещения." />
<EmptyState
v-if="!history.length"
title="История пока пуста"
subtitle="Завершённые лекции появятся здесь после посещения."
/>
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}</div>
<div class="lecture-meta">{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}</div>
<div class="lecture-meta">
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
</div>
<div class="lecture-meta">
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
</div>
</div>
<div class="lecture-actions">
<StatusBadge :status="item.status ?? 'completed'" />
@@ -86,7 +108,10 @@ async function confirmCancel() {
</div>
<ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm">
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p>
<p>
Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других
студентов.
</p>
<template #footer>
<button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button>
<button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button>
@@ -96,24 +121,68 @@ async function confirmCancel() {
</template>
<style scoped>
.my-lectures { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.tabs { display: inline-flex; width: fit-content; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
.my-lectures {
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
width: fit-content;
border: 1px solid var(--color-border-glass);
border-radius: 12px;
overflow: hidden;
}
.tabs button {
background: rgba(255,255,255,0.7);
background: rgba(255, 255, 255, 0.7);
border: none;
padding: 8px 18px;
font-size: 13px;
min-width: 110px;
text-align: center;
text-align: center;
cursor: pointer;
color: var(--color-text-secondary);
}
.tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; }
.list { display: flex; flex-direction: column; gap: 12px; }
.lecture-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap; }
.lecture-title { font-weight: 700; margin-bottom: 4px; }
.lecture-meta { font-size: 13px; color: var(--color-text-secondary); }
.lecture-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.tabs button.active {
background: rgba(34, 197, 94, 0.18);
color: var(--color-primary-dark);
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lecture-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.lecture-title {
font-weight: 700;
margin-bottom: 4px;
}
.lecture-meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.lecture-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
</style>
@@ -13,7 +13,7 @@ onMounted(() => {
const grouped = computed(() => {
const map: Record<string, typeof userStore.notifications> = {}
userStore.notifications.forEach(n => {
userStore.notifications.forEach((n) => {
const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
map[day] = map[day] || []
map[day].push(n)
@@ -34,7 +34,9 @@ const typeIcon: Record<string, string> = {
<div class="notifications page-content">
<div class="header">
<h1 class="page-title">Уведомления</h1>
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button>
<button class="btn-secondary" @click="userStore.markAllRead">
Отметить все как прочитанные
</button>
</div>
<div v-if="userStore.notifications.length === 0" class="empty-wrap">
@@ -46,7 +48,7 @@ const typeIcon: Record<string, string> = {
</div>
<div v-else class="notification-groups">
<GlassCard v-for="([day, items]) in grouped" :key="day" class="group">
<GlassCard v-for="[day, items] in grouped" :key="day" class="group">
<div class="group-title">{{ day }}</div>
<div class="items">
<div v-for="n in items" :key="n.id" class="item" :class="{ unread: !n.read }">
@@ -69,16 +71,53 @@ const typeIcon: Record<string, string> = {
gap: 18px;
min-height: calc(100vh - var(--topbar-height) - 28px - 80px);
}
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.header .page-title { margin-bottom: 0; }
.notification-groups { display: flex; flex-direction: column; gap: 14px; }
.group-title { font-weight: 700; margin-bottom: 10px; }
.items { display: flex; flex-direction: column; gap: 10px; }
.item { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
.item.unread { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.08); }
.icon { color: var(--color-text); flex-shrink: 0; }
.item-title { font-weight: 600; }
.item-body { font-size: 13px; color: var(--color-text-secondary); }
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.header .page-title {
margin-bottom: 0;
}
.notification-groups {
display: flex;
flex-direction: column;
gap: 14px;
}
.group-title {
font-weight: 700;
margin-bottom: 10px;
}
.items {
display: flex;
flex-direction: column;
gap: 10px;
}
.item {
display: flex;
gap: 12px;
padding: 10px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-border-glass);
}
.item.unread {
border-color: rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.08);
}
.icon {
color: var(--color-text);
flex-shrink: 0;
}
.item-title {
font-weight: 600;
}
.item-body {
font-size: 13px;
color: var(--color-text-secondary);
}
.empty-wrap {
flex: 1;
@@ -88,6 +127,8 @@ const typeIcon: Record<string, string> = {
}
@media (max-width: 768px) {
.notifications { min-height: calc(100vh - var(--topbar-height) - 16px - 80px); }
.notifications {
min-height: calc(100vh - var(--topbar-height) - 16px - 80px);
}
}
</style>
+99 -25
View File
@@ -27,8 +27,12 @@ const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const hasNextLevel = computed(
() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value,
)
const levelProgressMax = computed(() =>
hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1,
)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
@@ -37,27 +41,32 @@ const levelProgress = computed(() => {
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Уровень ${user.value.level}` : `Уровень ${user.value.level} · максимум`
: hasNextLevel.value
? `Уровень ${user.value.level}`
: `Уровень ${user.value.level} · максимум`,
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
hasNextLevel.value
? `${levelProgress.value} / ${levelProgressMax.value} XP`
: `${userXp.value} XP`,
)
const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0)
const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0)
const enrollmentSlotsRemaining = computed(() =>
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0)
Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0),
)
const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? [])
const enrollmentSlotText = computed(() =>
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...'
enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...',
)
const enrollmentSlotHint = computed(() => {
if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.'
if (enrollmentSlotsRemaining.value === 0) return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
if (enrollmentSlotsRemaining.value === 0)
return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.'
return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.`
})
const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked))
const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked))
const unlockedAchievements = computed(() => userStore.achievements.filter((a) => a.unlocked))
const lockedAchievements = computed(() => userStore.achievements.filter((a) => !a.unlocked))
const interestTags = ref([
{ label: '#ML', active: true },
{ label: '#ИИ', active: true },
@@ -73,7 +82,8 @@ function formatSlotCount(slots: number) {
const lastDigit = slots % 10
const lastTwoDigits = slots % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${slots} слота`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${slots} слота`
return `${slots} слотов`
}
@@ -81,7 +91,8 @@ function formatLectureCount(count: number) {
const lastDigit = count % 10
const lastTwoDigits = count % 100
if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${count} лекции`
if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14))
return `${count} лекции`
return `${count} лекций`
}
@@ -210,16 +221,60 @@ onMounted(() => {
</template>
<style scoped>
.profile { display: flex; flex-direction: column; gap: 20px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.user-info { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; }
.avatar { font-size: 38px; background: rgba(34,197,94,0.15); border-radius: 16px; padding: 12px; }
.name { font-weight: 700; font-size: 18px; }
.email, .meta { font-size: 13px; color: var(--color-text-secondary); }
.level { margin: 16px 0; }
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.profile {
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.user-info {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 16px;
}
.avatar {
font-size: 38px;
background: rgba(34, 197, 94, 0.15);
border-radius: 16px;
padding: 12px;
}
.name {
font-weight: 700;
font-size: 18px;
}
.email,
.meta {
font-size: 13px;
color: var(--color-text-secondary);
}
.level {
margin: 16px 0;
}
.level-header {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.tags-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.slot-overview {
display: flex;
align-items: center;
@@ -273,10 +328,29 @@ onMounted(() => {
border-color: var(--color-primary-a30);
background: var(--color-primary-a08);
}
.settings { display: flex; flex-direction: column; gap: 8px; }
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
.achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; }
.achievements-list { display: flex; flex-direction: column; gap: 12px; }
.settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting {
font-size: 13px;
color: var(--color-text-secondary);
display: flex;
gap: 8px;
align-items: center;
}
.achievements-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.achievements-list {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 520px) {
.slot-overview {
+89 -22
View File
@@ -21,7 +21,9 @@ const ratingMap = {
negative: 'Dislike',
} as const
const lectureTitle = computed(() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции')
const lectureTitle = computed(
() => lecture.value?.title || lecture.value?.courseName || 'Отзыв о лекции',
)
const lectureMeta = computed(() => {
if (!lecture.value) return ''
@@ -75,7 +77,9 @@ onMounted(() => {
<div>
<h1 class="page-title">{{ lectureLoading ? 'Загрузка лекции...' : lectureTitle }}</h1>
<p class="text-secondary">
{{ lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.' }}
{{
lectureMeta || 'Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.'
}}
</p>
</div>
</div>
@@ -85,7 +89,8 @@ onMounted(() => {
<AppIcon class="success-icon" icon="circle-check" :size="32" />
<div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается
техническая оценка LLM.
</div>
</div>
@@ -95,18 +100,39 @@ onMounted(() => {
<label class="field-label">Оценка впечатлений</label>
<div class="rating-options">
<button type="button" :class="{ active: rating === 'positive' }" @click="rating = 'positive'">👍 Положительный</button>
<button type="button" :class="{ active: rating === 'neutral' }" @click="rating = 'neutral'">😐 Нейтральный</button>
<button type="button" :class="{ active: rating === 'negative' }" @click="rating = 'negative'">👎 Отрицательный</button>
<button
type="button"
:class="{ active: rating === 'positive' }"
@click="rating = 'positive'"
>
👍 Положительный
</button>
<button
type="button"
:class="{ active: rating === 'neutral' }"
@click="rating = 'neutral'"
>
😐 Нейтральный
</button>
<button
type="button"
:class="{ active: rating === 'negative' }"
@click="rating = 'negative'"
>
👎 Отрицательный
</button>
</div>
<div class="hint">
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите
раскрыть глубже.
</div>
<div class="error" v-if="error">{{ error }}</div>
<div class="form-actions">
<button class="btn-primary" type="submit" :disabled="loading">{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}</button>
<button class="btn-primary" type="submit" :disabled="loading">
{{ loading ? 'Отправляем...' : 'Отправить отзыв' }}
</button>
</div>
</form>
</GlassCard>
@@ -114,37 +140,78 @@ onMounted(() => {
</template>
<style scoped>
.review { display: flex; flex-direction: column; gap: 16px; }
.form { display: flex; flex-direction: column; gap: 12px; }
.field-label { font-weight: 600; font-size: 13px; color: var(--color-text-secondary); }
.review {
display: flex;
flex-direction: column;
gap: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-label {
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary);
}
textarea {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.8);
background: rgba(255, 255, 255, 0.8);
font-size: 14px;
resize: vertical;
}
.rating-options { display: flex; gap: 10px; flex-wrap: wrap; }
.rating-options {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.rating-options button {
padding: 8px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.6);
background: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 13px;
}
.rating-options button.active {
border-color: var(--color-primary);
background: rgba(34,197,94,0.15);
background: rgba(34, 197, 94, 0.15);
color: var(--color-primary-dark);
font-weight: 600;
}
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); }
.form-actions { display: flex; gap: 10px; }
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.success-icon { color: var(--color-primary); }
.success-title { font-size: 16px; font-weight: 700; }
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
.error { color: var(--color-error); font-size: 13px; }
.hint {
font-size: 12px;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.6);
padding: 8px 12px;
border-radius: var(--radius-sm);
}
.form-actions {
display: flex;
gap: 10px;
}
.success-state {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.success-icon {
color: var(--color-primary);
}
.success-title {
font-size: 16px;
font-weight: 700;
}
.success-sub {
font-size: 13px;
color: var(--color-text-secondary);
}
.error {
color: var(--color-error);
font-size: 13px;
}
</style>