- Added new localization strings for scenarios with no due reviews in German, English, and Spanish, enhancing user guidance. - Updated VocabCourseView to conditionally display the appropriate introduction message based on the presence of due reviews, improving clarity in the course flow.
1444 lines
43 KiB
Vue
1444 lines
43 KiB
Vue
<template>
|
|
<div class="vocab-course-view">
|
|
<div v-if="loading" class="surface-card course-state">{{ $t('general.loading') }}</div>
|
|
<div v-else-if="course">
|
|
<section class="course-hero surface-card">
|
|
<div>
|
|
<span class="course-kicker">{{ $t('socialnetwork.vocab.courses.courseKicker') }}</span>
|
|
<h2>{{ course.title }}</h2>
|
|
<p v-if="course.description">{{ course.description }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="course-info surface-card">
|
|
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
|
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
|
<span v-if="course.shareCode && isOwner" class="share-code">
|
|
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
|
|
</span>
|
|
</div>
|
|
|
|
<section class="surface-card course-assistant">
|
|
<div>
|
|
<span class="course-assistant__eyebrow">{{ $t('socialnetwork.vocab.courses.languageAssistantEyebrow') }}</span>
|
|
<h3>{{ $t('socialnetwork.vocab.courses.languageAssistantCourseTitle') }}</h3>
|
|
<p>{{ assistantAvailable ? $t('socialnetwork.vocab.courses.languageAssistantCourseReady') : $t('socialnetwork.vocab.courses.languageAssistantCourseSetup') }}</p>
|
|
</div>
|
|
<div class="course-assistant__actions">
|
|
<button type="button" class="button-secondary" @click="openLanguageAssistantSettings">
|
|
{{ $t('socialnetwork.vocab.courses.languageAssistantSettings') }}
|
|
</button>
|
|
<button
|
|
v-if="assistantAvailable && currentLesson"
|
|
type="button"
|
|
@click="openLessonAssistant(currentLesson.id)"
|
|
>
|
|
{{ $t('socialnetwork.vocab.courses.languageAssistantOpenLesson') }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="course.lessons && course.lessons.length > 0" class="surface-card course-flow">
|
|
<div class="course-flow__header">
|
|
<div>
|
|
<span class="course-flow__eyebrow">{{ $t('socialnetwork.vocab.courses.courseFlowEyebrow') }}</span>
|
|
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowTitle') }}</h3>
|
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntro') }}</p>
|
|
</div>
|
|
<div class="course-flow__stats">
|
|
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowReviewStat', { count: dueReviewLessons.length }) }}</span>
|
|
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowBlockStat', { block: currentBlockNumber || '—' }) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="todayRecommendedSteps.length > 0" class="course-today-plan">
|
|
<h4 class="course-today-plan__title">{{ $t('socialnetwork.vocab.courses.courseTodayPlanTitle') }}</h4>
|
|
<p class="course-today-plan__intro">
|
|
{{ dueReviewLessons.length > 0
|
|
? $t('socialnetwork.vocab.courses.courseTodayPlanIntro')
|
|
: $t('socialnetwork.vocab.courses.courseTodayPlanIntroNoDueReview') }}
|
|
</p>
|
|
<ol class="course-today-plan__list">
|
|
<li v-for="(step, idx) in todayRecommendedSteps" :key="`${step.type}-${step.lesson.id}-${idx}`" class="course-today-plan__item">
|
|
<div class="course-today-plan__item-main">
|
|
<span class="course-today-plan__step-label">{{ todayPlanStepLabel(step.type) }}</span>
|
|
<span class="course-today-plan__lesson-title">{{ step.lesson.title }}</span>
|
|
<span class="course-today-plan__lesson-meta">#{{ step.lesson.lessonNumber }}</span>
|
|
</div>
|
|
<button type="button" class="course-today-plan__action" @click="openTodayPlanStep(step)">
|
|
{{ step.type === 'practice' ? $t('socialnetwork.vocab.courses.courseTodayPlanTrainer') : $t('socialnetwork.vocab.courses.courseTodayPlanOpen') }}
|
|
</button>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
<div v-else class="course-today-plan course-today-plan--empty">
|
|
<h4 class="course-today-plan__title">{{ $t('socialnetwork.vocab.courses.courseTodayPlanTitle') }}</h4>
|
|
<p class="course-today-plan__intro">{{ $t('socialnetwork.vocab.courses.courseTodayPlanEmpty') }}</p>
|
|
</div>
|
|
|
|
<div class="course-flow__grid">
|
|
<article v-if="dueReviewLessons.length > 0" class="course-flow-card">
|
|
<div class="course-flow-card__top">
|
|
<span class="course-flow-card__badge course-flow-card__badge--review">1</span>
|
|
<div>
|
|
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowReviewTitle') }}</h4>
|
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowReviewDescription') }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="dueReviewLessons.length > 0" class="course-flow-card__list">
|
|
<button
|
|
v-for="lesson in dueReviewLessons"
|
|
:key="`due-${lesson.id}`"
|
|
type="button"
|
|
class="course-flow-lesson"
|
|
@click="openLessonReview(lesson.id)"
|
|
>
|
|
<strong>{{ lesson.title }}</strong>
|
|
<span>{{ formatReviewDue(getLessonProgress(lesson.id, lesson)?.reviewNextDueAt) }}</span>
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="course-flow-card">
|
|
<div class="course-flow-card__top">
|
|
<span class="course-flow-card__badge course-flow-card__badge--block">{{ dueReviewLessons.length > 0 ? 2 : 1 }}</span>
|
|
<div>
|
|
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowBlockTitle') }}</h4>
|
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowBlockDescription') }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="currentBlockLessons.length > 0" class="course-flow-card__list">
|
|
<button
|
|
v-for="lesson in currentBlockLessons"
|
|
:key="`block-${lesson.id}`"
|
|
type="button"
|
|
class="course-flow-lesson"
|
|
@click="openLesson(lesson.id)"
|
|
>
|
|
<strong>{{ lesson.title }}</strong>
|
|
<span>#{{ lesson.lessonNumber }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowBlockEmpty') }}</p>
|
|
</article>
|
|
|
|
<article class="course-flow-card">
|
|
<div class="course-flow-card__top">
|
|
<span class="course-flow-card__badge course-flow-card__badge--intensive">{{ dueReviewLessons.length > 0 ? 3 : 2 }}</span>
|
|
<div>
|
|
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveTitle') }}</h4>
|
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveDescription') }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="nextIntensiveReviewLesson" class="course-flow-card__list">
|
|
<button
|
|
type="button"
|
|
class="course-flow-lesson"
|
|
@click="openLesson(nextIntensiveReviewLesson.id)"
|
|
>
|
|
<strong>{{ nextIntensiveReviewLesson.title }}</strong>
|
|
<span>{{ $t('socialnetwork.vocab.courses.lessonBlockLabel', { number: nextIntensiveReviewLesson.pedagogy?.blockNumber || '—' }) }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveEmpty') }}</p>
|
|
</article>
|
|
|
|
<article class="course-flow-card">
|
|
<div class="course-flow-card__top">
|
|
<span class="course-flow-card__badge course-flow-card__badge--practice">{{ dueReviewLessons.length > 0 ? 4 : 3 }}</span>
|
|
<div>
|
|
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowPracticeTitle') }}</h4>
|
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowPracticeDescription') }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="freePracticeLessons.length > 0" class="course-flow-card__list">
|
|
<button
|
|
v-for="lesson in freePracticeLessons"
|
|
:key="`practice-${lesson.id}`"
|
|
type="button"
|
|
class="course-flow-lesson"
|
|
@click="openLessonPractice(lesson)"
|
|
>
|
|
<strong>{{ lesson.title }}</strong>
|
|
<span>{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}</span>
|
|
</button>
|
|
</div>
|
|
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowPracticeEmpty') }}</p>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<div v-if="isOwner" class="owner-actions">
|
|
<button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button>
|
|
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
|
</div>
|
|
|
|
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list surface-card">
|
|
<div class="current-lesson-section" v-if="currentLesson">
|
|
<button @click="openLesson(currentLesson.id)" class="btn-current-lesson">
|
|
{{ $t('socialnetwork.vocab.courses.continueCurrentLesson') }}
|
|
</button>
|
|
</div>
|
|
<div class="lessons-header">
|
|
<h3>{{ $t('socialnetwork.vocab.courses.lessons') }}</h3>
|
|
<span class="lessons-count">{{ $t('socialnetwork.vocab.courses.lessonsCount', { count: course.lessons.length }) }}</span>
|
|
</div>
|
|
<div class="lesson-cards">
|
|
<article v-for="lesson in sortedLessons" :key="lesson.id" class="lesson-card">
|
|
<div class="lesson-card__header">
|
|
<span class="lesson-number">#{{ lesson.lessonNumber }}</span>
|
|
<div class="lesson-status-content">
|
|
<span v-if="getLessonProgress(lesson.id, lesson)?.completed" class="badge completed">
|
|
{{ $t('socialnetwork.vocab.courses.completed') }}
|
|
</span>
|
|
<span v-else-if="getLessonProgress(lesson.id, lesson)?.score" class="score">
|
|
{{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id, lesson).score }}%
|
|
</span>
|
|
<span v-else class="status-new">
|
|
{{ $t('socialnetwork.vocab.courses.notStarted') }}
|
|
</span>
|
|
<span
|
|
v-if="getReviewBadgeLabel(getLessonProgress(lesson.id, lesson))"
|
|
class="review-badge"
|
|
:class="getReviewBadgeClass(getLessonProgress(lesson.id, lesson))"
|
|
:title="getReviewBadgeTooltip(getLessonProgress(lesson.id, lesson))"
|
|
>
|
|
{{ getReviewBadgeLabel(getLessonProgress(lesson.id, lesson)) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="lesson-title-content">
|
|
<span class="title-label">{{ lesson.title }}</span>
|
|
<span v-if="lesson.description" class="lesson-description">{{ lesson.description }}</span>
|
|
</div>
|
|
<div class="lesson-pedagogy" v-if="lesson.pedagogy">
|
|
<span class="lesson-chip lesson-chip--phase">{{ getPhaseLabel(lesson.pedagogy.phaseLabel) }}</span>
|
|
<span class="lesson-chip lesson-chip--mode">{{ getDidacticModeLabel(lesson.pedagogy.didacticMode) }}</span>
|
|
<span v-if="lesson.pedagogy.blockNumber" class="lesson-chip lesson-chip--block">{{ $t('socialnetwork.vocab.courses.lessonBlockLabel', { number: lesson.pedagogy.blockNumber }) }}</span>
|
|
<span v-if="lesson.pedagogy.isIntensiveReview" class="lesson-chip lesson-chip--intensive">{{ $t('socialnetwork.vocab.courses.lessonIntensiveBadge') }}</span>
|
|
</div>
|
|
<div class="lesson-actions-content">
|
|
<button
|
|
@click="openLesson(lesson.id)"
|
|
class="btn-start"
|
|
:disabled="!canStartLesson(lesson)"
|
|
:title="!canStartLesson(lesson) ? $t('socialnetwork.vocab.courses.previousLessonRequired') : ''"
|
|
>
|
|
{{ getLessonProgress(lesson.id, lesson)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
|
|
</button>
|
|
<button
|
|
v-if="canShowLessonTrainer(lesson)"
|
|
@click="openLessonPractice(lesson)"
|
|
class="btn-edit"
|
|
>
|
|
{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}
|
|
</button>
|
|
<button v-if="isOwner" @click="editLesson(lesson.id)" class="btn-edit">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
|
<button v-if="isOwner" @click="deleteLesson(lesson.id)" class="btn-delete">{{ $t('general.delete') }}</button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<p class="surface-card course-state">{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<VocabPracticeDialog ref="practiceDialog" />
|
|
|
|
<!-- Add Lesson Dialog -->
|
|
<div v-if="showAddLessonDialog" class="dialog-overlay" @click="showAddLessonDialog = false">
|
|
<div class="dialog" @click.stop>
|
|
<h3>{{ $t('socialnetwork.vocab.courses.addLesson') }}</h3>
|
|
<form @submit.prevent="addLesson">
|
|
<div class="form-group form-field">
|
|
<label>{{ $t('socialnetwork.vocab.courses.lessonNumber') }}</label>
|
|
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required :class="{ 'field-error': lessonFormTouched && !isLessonNumberValid }" />
|
|
</div>
|
|
<div class="form-group form-field">
|
|
<label>{{ $t('socialnetwork.vocab.courses.title') }}</label>
|
|
<input v-model="newLesson.title" required :class="{ 'field-error': lessonFormTouched && !isLessonTitleValid }" />
|
|
</div>
|
|
<div class="form-group form-field">
|
|
<label>{{ $t('socialnetwork.vocab.courses.description') }}</label>
|
|
<textarea v-model="newLesson.description"></textarea>
|
|
</div>
|
|
<div class="form-group form-field">
|
|
<label>{{ $t('socialnetwork.vocab.courses.chapter') }}</label>
|
|
<select v-model="newLesson.chapterId" required :class="{ 'field-error': lessonFormTouched && !isLessonChapterValid }">
|
|
<option value="">{{ $t('socialnetwork.vocab.courses.selectChapter') }}</option>
|
|
<option v-for="chapter in chapters" :key="chapter.id" :value="chapter.id">{{ chapter.title }}</option>
|
|
</select>
|
|
</div>
|
|
<span v-if="lessonFormTouched && !canCreateLesson" class="form-error">{{ $t('socialnetwork.vocab.courses.addLessonValidation') }}</span>
|
|
<div class="form-actions form-actions-row">
|
|
<button type="submit" :disabled="!canCreateLesson">{{ $t('general.create') }}</button>
|
|
<button type="button" @click="showAddLessonDialog = false" class="button-secondary">{{ $t('general.cancel') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import apiClient from '@/utils/axios.js';
|
|
import { confirmAction, showApiError, showInfo, showSuccess } from '@/utils/feedback.js';
|
|
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
|
|
|
|
export default {
|
|
name: 'VocabCourseView',
|
|
components: { VocabPracticeDialog },
|
|
props: {
|
|
courseId: {
|
|
type: String,
|
|
required: true
|
|
}
|
|
},
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
course: null,
|
|
progress: [],
|
|
chapters: [],
|
|
showAddLessonDialog: false,
|
|
assistantSettings: null,
|
|
lessonFormTouched: false,
|
|
newLesson: {
|
|
lessonNumber: 1,
|
|
title: '',
|
|
description: '',
|
|
chapterId: null
|
|
}
|
|
};
|
|
},
|
|
computed: {
|
|
...mapGetters(['user']),
|
|
isOwner() {
|
|
return this.course && this.course.ownerUserId === this.user?.id;
|
|
},
|
|
sortedLessons() {
|
|
if (!this.course?.lessons) {
|
|
return [];
|
|
}
|
|
return [...this.course.lessons].sort((a, b) => a.lessonNumber - b.lessonNumber);
|
|
},
|
|
currentLesson() {
|
|
if (this.sortedLessons.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Finde die erste nicht abgeschlossene Lektion
|
|
for (const lesson of this.sortedLessons) {
|
|
const progress = this.getLessonProgress(lesson.id, lesson);
|
|
if (!progress || !progress.completed) {
|
|
return lesson;
|
|
}
|
|
}
|
|
|
|
// Alle Lektionen abgeschlossen - zeige die letzte Lektion
|
|
return this.sortedLessons[this.sortedLessons.length - 1];
|
|
},
|
|
currentBlockNumber() {
|
|
return this.currentLesson?.pedagogy?.blockNumber || null;
|
|
},
|
|
dueReviewLessons() {
|
|
return this.sortedLessons
|
|
.filter((lesson) => {
|
|
const progress = this.getLessonProgress(lesson.id, lesson);
|
|
return Boolean(progress?.completed && progress?.reviewDue);
|
|
})
|
|
.sort((a, b) => {
|
|
const left = this.getLessonProgress(a.id, a)?.reviewNextDueAt || '';
|
|
const right = this.getLessonProgress(b.id, b)?.reviewNextDueAt || '';
|
|
return left.localeCompare(right);
|
|
})
|
|
.slice(0, 4);
|
|
},
|
|
currentBlockLessons() {
|
|
if (!this.currentBlockNumber) {
|
|
return [];
|
|
}
|
|
return this.sortedLessons.filter((lesson) => {
|
|
const lessonBlock = lesson.pedagogy?.blockNumber;
|
|
if (lessonBlock !== this.currentBlockNumber) {
|
|
return false;
|
|
}
|
|
return !this.getLessonProgress(lesson.id, lesson)?.completed;
|
|
});
|
|
},
|
|
nextIntensiveReviewLesson() {
|
|
return this.sortedLessons.find((lesson) => {
|
|
const isIntensive = lesson.pedagogy?.didacticMode === 'intensive_review' || lesson.pedagogy?.isIntensiveReview;
|
|
if (!isIntensive) return false;
|
|
if (this.getLessonProgress(lesson.id, lesson)?.completed) return false;
|
|
|
|
const blockNumber = lesson.pedagogy?.blockNumber;
|
|
const blockLessons = this.sortedLessons.filter((candidate) => {
|
|
if ((candidate.pedagogy?.blockNumber || null) !== blockNumber) return false;
|
|
const candidateIsIntensive = candidate.pedagogy?.didacticMode === 'intensive_review' || candidate.pedagogy?.isIntensiveReview;
|
|
return !candidateIsIntensive && candidate.lessonNumber < lesson.lessonNumber;
|
|
});
|
|
|
|
return blockLessons.length > 0 && blockLessons.every((candidate) => this.getLessonProgress(candidate.id, candidate)?.completed);
|
|
}) || null;
|
|
},
|
|
freePracticeLessons() {
|
|
return this.sortedLessons
|
|
.filter((lesson) => {
|
|
const progress = this.getLessonProgress(lesson.id, lesson);
|
|
return Boolean(progress?.completed) && !progress?.reviewDue;
|
|
})
|
|
.sort((a, b) => {
|
|
const left = this.lastProgressTouch(a.id, a) || '';
|
|
const right = this.lastProgressTouch(b.id, b) || '';
|
|
return right.localeCompare(left);
|
|
})
|
|
.slice(0, 4);
|
|
},
|
|
todayRecommendedSteps() {
|
|
const out = [];
|
|
const seen = new Set();
|
|
const push = (type, lesson) => {
|
|
if (!lesson?.id || seen.has(lesson.id)) {
|
|
return;
|
|
}
|
|
seen.add(lesson.id);
|
|
out.push({ type, lesson });
|
|
};
|
|
this.dueReviewLessons.forEach((l) => push('review_due', l));
|
|
this.currentBlockLessons.slice(0, 3).forEach((l) => push('block', l));
|
|
if (this.nextIntensiveReviewLesson) {
|
|
push('intensive', this.nextIntensiveReviewLesson);
|
|
}
|
|
if (out.length === 0 && this.currentLesson) {
|
|
const p = this.getLessonProgress(this.currentLesson.id, this.currentLesson);
|
|
if (!p?.completed) {
|
|
push('continue', this.currentLesson);
|
|
}
|
|
}
|
|
if (out.length === 0 && this.freePracticeLessons.length > 0) {
|
|
push('practice', this.freePracticeLessons[0]);
|
|
}
|
|
return out.slice(0, 8);
|
|
},
|
|
isLessonNumberValid() {
|
|
return Number(this.newLesson.lessonNumber) > 0;
|
|
},
|
|
isLessonTitleValid() {
|
|
return this.newLesson.title.trim().length >= 3;
|
|
},
|
|
isLessonChapterValid() {
|
|
return Boolean(this.newLesson.chapterId);
|
|
},
|
|
canCreateLesson() {
|
|
return this.isLessonNumberValid && this.isLessonTitleValid && this.isLessonChapterValid;
|
|
},
|
|
assistantAvailable() {
|
|
if (!this.assistantSettings) {
|
|
return false;
|
|
}
|
|
const enabled = this.assistantSettings.enabled !== false;
|
|
const hasBaseUrl = Boolean(this.assistantSettings.baseUrl);
|
|
return enabled && (this.assistantSettings.hasKey || hasBaseUrl);
|
|
}
|
|
},
|
|
watch: {
|
|
courseId() {
|
|
this.loadCourse();
|
|
}
|
|
},
|
|
methods: {
|
|
async loadCourse() {
|
|
this.loading = true;
|
|
try {
|
|
const res = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
|
|
this.course = res.data;
|
|
await this.loadProgress();
|
|
if (this.course.languageId) {
|
|
await this.loadChapters();
|
|
}
|
|
} catch (e) {
|
|
console.error('Konnte Kurs nicht laden:', e);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
async loadProgress() {
|
|
try {
|
|
const res = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`);
|
|
this.progress = res.data || [];
|
|
} catch (e) {
|
|
// Nicht eingeschrieben? Progress ist leer
|
|
this.progress = [];
|
|
}
|
|
},
|
|
async loadChapters() {
|
|
try {
|
|
const res = await apiClient.get(`/api/vocab/languages/${this.course.languageId}/chapters`);
|
|
this.chapters = res.data?.chapters || [];
|
|
} catch (e) {
|
|
console.error('Konnte Kapitel nicht laden:', e);
|
|
}
|
|
},
|
|
async loadAssistantSettings() {
|
|
try {
|
|
const { data } = await apiClient.get('/api/settings/llm');
|
|
this.assistantSettings = data;
|
|
} catch (e) {
|
|
this.assistantSettings = null;
|
|
}
|
|
},
|
|
getLessonProgress(lessonId, lesson = null) {
|
|
const id = lessonId == null ? NaN : Number(lessonId);
|
|
const byId = this.progress.find((p) => Number(p.lessonId) === id);
|
|
if (byId) {
|
|
return byId;
|
|
}
|
|
const num = lesson?.lessonNumber;
|
|
if (num != null && Number.isFinite(Number(num))) {
|
|
const n = Number(num);
|
|
return this.progress.find((p) => Number(p.lessonNumber) === n) || null;
|
|
}
|
|
return null;
|
|
},
|
|
lastProgressTouch(lessonId, lesson = null) {
|
|
const progress = this.getLessonProgress(lessonId, lesson);
|
|
return progress?.lastAccessedAt || progress?.completedAt || progress?.updatedAt || '';
|
|
},
|
|
daysSince(dateString) {
|
|
if (!dateString) {
|
|
return 0;
|
|
}
|
|
const timestamp = new Date(dateString).getTime();
|
|
if (!Number.isFinite(timestamp)) {
|
|
return 0;
|
|
}
|
|
const diff = Date.now() - timestamp;
|
|
return Math.max(0, Math.floor(diff / (24 * 60 * 60 * 1000)));
|
|
},
|
|
formatDaysSince(dateString) {
|
|
const days = this.daysSince(dateString);
|
|
if (days <= 0) {
|
|
return this.$t('socialnetwork.vocab.courses.timeToday');
|
|
}
|
|
if (days === 1) {
|
|
return this.$t('socialnetwork.vocab.courses.timeSinceOneDay');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.timeSinceDays', { count: days });
|
|
},
|
|
formatReviewDue(reviewNextDueAt) {
|
|
if (!reviewNextDueAt) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueNow');
|
|
}
|
|
const dueTimestamp = new Date(reviewNextDueAt).getTime();
|
|
if (!Number.isFinite(dueTimestamp)) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueNow');
|
|
}
|
|
const diffMs = dueTimestamp - Date.now();
|
|
if (diffMs > 0) {
|
|
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
|
|
if (untilDays <= 1) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueTomorrow');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueInDays', { count: untilDays });
|
|
}
|
|
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
|
|
if (diffDays <= 0) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueToday');
|
|
}
|
|
if (diffDays === 1) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueSinceOneDay');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewDueSinceDays', { count: diffDays });
|
|
},
|
|
formatReviewWhenFriendly(reviewNextDueAt) {
|
|
if (!reviewNextDueAt) {
|
|
return '';
|
|
}
|
|
const dueTimestamp = new Date(reviewNextDueAt).getTime();
|
|
if (!Number.isFinite(dueTimestamp)) {
|
|
return '';
|
|
}
|
|
const diffMs = dueTimestamp - Date.now();
|
|
if (diffMs > 0) {
|
|
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
|
|
if (untilDays <= 1) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyTomorrow');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyInDays', { count: untilDays });
|
|
}
|
|
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
|
|
if (diffDays <= 0) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyToday');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewWhenFriendlyOverdue', { count: diffDays });
|
|
},
|
|
canShowLessonTrainer(lesson) {
|
|
const p = this.getLessonProgress(lesson.id, lesson);
|
|
if (p?.completed) {
|
|
return true;
|
|
}
|
|
if (this.canStartLesson(lesson)) {
|
|
return true;
|
|
}
|
|
if (p && Number(p.score) > 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
getReviewBadgeLabel(progress) {
|
|
if (!progress?.completed) {
|
|
return '';
|
|
}
|
|
if (progress.reviewCompleted) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineAllDone');
|
|
}
|
|
const stage = Number(progress.reviewStage ?? 0);
|
|
const step = Math.min(3, stage + 1);
|
|
if (progress.reviewDue) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineDue', { step });
|
|
}
|
|
const when = this.formatReviewWhenFriendly(progress.reviewNextDueAt);
|
|
if (when) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineScheduled', { step, when });
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeLineScheduled', {
|
|
step,
|
|
when: this.$t('socialnetwork.vocab.courses.reviewWhenFriendlySoon')
|
|
});
|
|
},
|
|
getReviewBadgeTooltip(progress) {
|
|
if (!progress?.completed) {
|
|
return '';
|
|
}
|
|
if (progress.reviewCompleted) {
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeTooltipDone');
|
|
}
|
|
return this.$t('socialnetwork.vocab.courses.reviewBadgeTooltipActive');
|
|
},
|
|
getReviewBadgeClass(progress) {
|
|
if (!progress?.completed) {
|
|
return '';
|
|
}
|
|
if (progress.reviewCompleted) {
|
|
return 'review-badge--done';
|
|
}
|
|
if (progress.reviewDue) {
|
|
return 'review-badge--due';
|
|
}
|
|
return 'review-badge--scheduled';
|
|
},
|
|
canStartLesson(lesson) {
|
|
if (!this.course || !this.course.lessons) {
|
|
return false;
|
|
}
|
|
|
|
// Finde den Index der aktuellen Lektion
|
|
const currentIndex = this.sortedLessons.findIndex(l => l.id === lesson.id);
|
|
|
|
// Die erste Lektion kann immer gestartet werden
|
|
if (currentIndex === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Wenn es nicht die erste Lektion ist, prüfe ob die vorherige abgeschlossen wurde
|
|
if (currentIndex > 0) {
|
|
const previousLesson = this.sortedLessons[currentIndex - 1];
|
|
const previousProgress = this.getLessonProgress(previousLesson.id, previousLesson);
|
|
return previousProgress && previousProgress.completed;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
async addLesson() {
|
|
this.lessonFormTouched = true;
|
|
if (!this.canCreateLesson) {
|
|
return;
|
|
}
|
|
try {
|
|
await apiClient.post(`/api/vocab/courses/${this.courseId}/lessons`, this.newLesson);
|
|
this.showAddLessonDialog = false;
|
|
this.lessonFormTouched = false;
|
|
this.newLesson = {
|
|
lessonNumber: 1,
|
|
title: '',
|
|
description: '',
|
|
chapterId: null
|
|
};
|
|
await this.loadCourse();
|
|
showSuccess(this, this.$t('socialnetwork.vocab.courses.addLessonSuccess'));
|
|
} catch (e) {
|
|
console.error('Fehler beim Hinzufügen der Lektion:', e);
|
|
showApiError(this, e, this.$t('socialnetwork.vocab.courses.addLessonError'));
|
|
}
|
|
},
|
|
async deleteLesson(lessonId) {
|
|
const confirmed = await confirmAction(this, {
|
|
title: this.$t('socialnetwork.vocab.courses.deleteLessonTitle'),
|
|
message: this.$t('socialnetwork.vocab.courses.confirmDelete')
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
try {
|
|
await apiClient.delete(`/api/vocab/lessons/${lessonId}`);
|
|
await this.loadCourse();
|
|
showSuccess(this, this.$t('socialnetwork.vocab.courses.deleteLessonSuccess'));
|
|
} catch (e) {
|
|
console.error('Fehler beim Löschen der Lektion:', e);
|
|
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
|
|
}
|
|
},
|
|
openLesson(lessonId) {
|
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
|
},
|
|
todayPlanStepLabel(type) {
|
|
const key = {
|
|
review_due: 'courseTodayPlanStepReviewDue',
|
|
block: 'courseTodayPlanStepBlock',
|
|
intensive: 'courseTodayPlanStepIntensive',
|
|
continue: 'courseTodayPlanStepContinue',
|
|
practice: 'courseTodayPlanStepPractice'
|
|
}[type];
|
|
return key ? this.$t(`socialnetwork.vocab.courses.${key}`) : '';
|
|
},
|
|
openTodayPlanStep(step) {
|
|
if (!step?.lesson) {
|
|
return;
|
|
}
|
|
if (step.type === 'review_due') {
|
|
this.openLessonReview(step.lesson.id);
|
|
return;
|
|
}
|
|
if (step.type === 'practice') {
|
|
this.openLessonPractice(step.lesson);
|
|
return;
|
|
}
|
|
this.openLesson(step.lesson.id);
|
|
},
|
|
openLessonPractice(lesson) {
|
|
this.$refs.practiceDialog?.open?.({
|
|
courseId: this.courseId,
|
|
lessonId: lesson.id
|
|
});
|
|
},
|
|
openLessonReview(lessonId) {
|
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}/review`);
|
|
},
|
|
openLessonAssistant(lessonId) {
|
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}?assistant=1`);
|
|
},
|
|
editCourse() {
|
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/edit`);
|
|
},
|
|
openLanguageAssistantSettings() {
|
|
this.$router.push('/settings/language-assistant');
|
|
},
|
|
getPhaseLabel(phaseLabel) {
|
|
switch (phaseLabel) {
|
|
case 'quickstart':
|
|
return this.$t('socialnetwork.vocab.courses.phaseQuickstart');
|
|
case 'daily_life':
|
|
return this.$t('socialnetwork.vocab.courses.phaseDailyLife');
|
|
case 'stabilization':
|
|
return this.$t('socialnetwork.vocab.courses.phaseStabilization');
|
|
default:
|
|
return this.$t('socialnetwork.vocab.courses.phaseDefault');
|
|
}
|
|
},
|
|
getDidacticModeLabel(didacticMode) {
|
|
switch (didacticMode) {
|
|
case 'core_input':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeCoreInput');
|
|
case 'guided_dialogue':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeGuidedDialogue');
|
|
case 'contrast_training':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeContrastTraining');
|
|
case 'pattern_drill':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModePatternDrill');
|
|
case 'real_life_scenario':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeRealLifeScenario');
|
|
case 'intensive_review':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeIntensiveReview');
|
|
case 'checkpoint':
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeCheckpoint');
|
|
default:
|
|
return this.$t('socialnetwork.vocab.courses.didacticModeDefault');
|
|
}
|
|
},
|
|
editLesson() {
|
|
showInfo(this, this.$t('socialnetwork.vocab.courses.editLessonPending'));
|
|
}
|
|
},
|
|
async mounted() {
|
|
await Promise.all([
|
|
this.loadCourse(),
|
|
this.loadAssistantSettings()
|
|
]);
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.vocab-course-view {
|
|
max-width: var(--content-max-width);
|
|
margin: 0 auto;
|
|
padding: 0 0 24px;
|
|
}
|
|
|
|
.course-hero,
|
|
.course-info,
|
|
.course-assistant,
|
|
.course-flow,
|
|
.lessons-list,
|
|
.course-state {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.course-hero {
|
|
padding: 24px 26px;
|
|
}
|
|
|
|
.course-kicker {
|
|
display: inline-block;
|
|
margin-bottom: 10px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(248, 162, 43, 0.14);
|
|
color: #8a5411;
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.course-hero p {
|
|
margin: 0;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.course-info {
|
|
display: flex;
|
|
gap: 15px;
|
|
margin: 0 0 16px;
|
|
color: #666;
|
|
flex-wrap: wrap;
|
|
padding: 16px 18px;
|
|
}
|
|
|
|
.course-assistant {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 18px 20px;
|
|
}
|
|
|
|
.course-assistant__eyebrow {
|
|
display: inline-block;
|
|
margin-bottom: 8px;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.course-assistant h3,
|
|
.course-assistant p {
|
|
margin: 0;
|
|
}
|
|
|
|
.course-assistant p {
|
|
margin-top: 6px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.course-assistant__actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.course-flow {
|
|
padding: 20px;
|
|
}
|
|
|
|
.course-flow__header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.course-flow__eyebrow {
|
|
display: inline-block;
|
|
margin-bottom: 8px;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.course-flow__header h3,
|
|
.course-flow__header p {
|
|
margin: 0;
|
|
}
|
|
|
|
.course-flow__header p {
|
|
margin-top: 6px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.course-flow__stats {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.course-flow__stat {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(93, 64, 55, 0.08);
|
|
color: #6d5446;
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.course-flow__grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.course-flow-card {
|
|
padding: 16px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
background: rgba(255, 255, 255, 0.72);
|
|
}
|
|
|
|
.course-flow-card__top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.course-flow-card__top h4,
|
|
.course-flow-card__top p {
|
|
margin: 0;
|
|
}
|
|
|
|
.course-flow-card__top p {
|
|
margin-top: 4px;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.course-flow-card__badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 999px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.course-flow-card__badge--review {
|
|
background: rgba(68, 138, 86, 0.16);
|
|
color: #2f6b3d;
|
|
}
|
|
|
|
.course-flow-card__badge--block {
|
|
background: rgba(248, 162, 43, 0.18);
|
|
color: #8a5411;
|
|
}
|
|
|
|
.course-flow-card__badge--intensive {
|
|
background: rgba(207, 78, 78, 0.16);
|
|
color: #a13f3f;
|
|
}
|
|
|
|
.course-flow-card__badge--practice {
|
|
background: rgba(34, 96, 164, 0.14);
|
|
color: #21598f;
|
|
}
|
|
|
|
.course-flow-card__list {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.course-flow-lesson {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
width: 100%;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background: #fff;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.course-flow-lesson strong {
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.course-flow-lesson span {
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.84rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.course-flow-card__empty {
|
|
margin: 0;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.share-code {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.share-code code {
|
|
background: #f0f0f0;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.owner-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.lessons-list {
|
|
margin-top: 0;
|
|
padding: 20px;
|
|
}
|
|
|
|
.lessons-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.lessons-count {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.84rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.course-state {
|
|
padding: 18px;
|
|
text-align: center;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.current-lesson-section {
|
|
margin-bottom: 20px;
|
|
padding: 15px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn-current-lesson {
|
|
padding: 12px 24px;
|
|
background: var(--color-primary);
|
|
color: #2b1f14;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
transition: background 0.2s, box-shadow 0.2s;
|
|
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.18);
|
|
}
|
|
|
|
.btn-current-lesson:hover {
|
|
background: var(--color-primary-hover);
|
|
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.22);
|
|
}
|
|
|
|
.lesson-cards {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.lesson-number {
|
|
font-weight: 600;
|
|
color: #666;
|
|
font-size: 0.95em;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 48px;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(248, 162, 43, 0.12);
|
|
}
|
|
|
|
.lesson-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
padding: 16px 18px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
background: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
.lesson-pedagogy {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.lesson-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.lesson-chip--phase {
|
|
background: rgba(120, 195, 138, 0.16);
|
|
color: #42634e;
|
|
}
|
|
|
|
.lesson-chip--mode {
|
|
background: rgba(248, 162, 43, 0.16);
|
|
color: #8a5411;
|
|
}
|
|
|
|
.lesson-chip--block {
|
|
background: rgba(93, 64, 55, 0.09);
|
|
color: #6d5446;
|
|
}
|
|
|
|
.lesson-chip--intensive {
|
|
background: rgba(207, 78, 78, 0.14);
|
|
color: #a13f3f;
|
|
}
|
|
|
|
.lesson-card__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.lesson-title-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.title-label {
|
|
font-weight: 500;
|
|
color: #333;
|
|
font-size: 1em;
|
|
}
|
|
|
|
.lesson-description {
|
|
color: #666;
|
|
font-size: 0.85em;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.lesson-status-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.badge.completed {
|
|
background: #4CAF50;
|
|
color: white;
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 0.8em;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.score {
|
|
color: #666;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.status-new {
|
|
color: #999;
|
|
font-size: 0.85em;
|
|
font-style: italic;
|
|
}
|
|
|
|
.review-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 0.78em;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.review-badge--scheduled {
|
|
background: rgba(34, 96, 164, 0.12);
|
|
color: #21598f;
|
|
}
|
|
|
|
.review-badge--due {
|
|
background: rgba(185, 99, 24, 0.14);
|
|
color: #8d5412;
|
|
}
|
|
|
|
.review-badge--done {
|
|
background: rgba(68, 138, 86, 0.14);
|
|
color: #2f6b3d;
|
|
}
|
|
|
|
.lesson-actions {
|
|
display: block;
|
|
}
|
|
|
|
.lesson-actions-content {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.btn-start {
|
|
padding: 8px 16px;
|
|
background: var(--color-primary);
|
|
color: #2b1f14;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
font-weight: 500;
|
|
transition: background 0.2s, box-shadow 0.2s;
|
|
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.18);
|
|
}
|
|
|
|
.btn-start:hover:not(:disabled) {
|
|
background: var(--color-primary-hover);
|
|
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.22);
|
|
}
|
|
|
|
.btn-start:disabled {
|
|
background: #e0e0e0;
|
|
color: #999;
|
|
border: 1px solid #ccc;
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.btn-edit {
|
|
padding: 6px 12px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
color: var(--color-text-primary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
transition: background 0.2s, border-color 0.2s;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.btn-edit:hover {
|
|
background: rgba(255, 255, 255, 0.98);
|
|
border: 1px solid var(--color-border-strong);
|
|
}
|
|
|
|
.btn-delete {
|
|
padding: 6px 12px;
|
|
background: rgba(177, 59, 53, 0.92);
|
|
color: white;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.btn-delete:hover {
|
|
background: var(--color-danger-hover);
|
|
}
|
|
|
|
.dialog-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.dialog {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.form-group {
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group textarea,
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 80px;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.course-today-plan {
|
|
margin-bottom: 20px;
|
|
padding: 16px 18px;
|
|
border-radius: 12px;
|
|
background: rgba(255, 248, 235, 0.75);
|
|
border: 1px solid rgba(212, 184, 150, 0.45);
|
|
}
|
|
|
|
.course-today-plan--empty {
|
|
background: rgba(250, 250, 250, 0.85);
|
|
border-color: var(--color-border, #e0dcd6);
|
|
}
|
|
|
|
.course-today-plan__title {
|
|
margin: 0 0 8px;
|
|
font-size: 1.05rem;
|
|
font-weight: 650;
|
|
color: var(--color-text-primary, #3a322c);
|
|
}
|
|
|
|
.course-today-plan__intro {
|
|
margin: 0 0 14px;
|
|
font-size: 0.88rem;
|
|
line-height: 1.5;
|
|
color: var(--color-text-secondary, #5c534c);
|
|
}
|
|
|
|
.course-today-plan__list {
|
|
margin: 0;
|
|
padding-left: 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.course-today-plan__item {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
padding-bottom: 2px;
|
|
}
|
|
|
|
.course-today-plan__item-main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.course-today-plan__step-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
color: #8a6d3b;
|
|
}
|
|
|
|
.course-today-plan__lesson-title {
|
|
font-weight: 600;
|
|
color: #333;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.course-today-plan__lesson-meta {
|
|
font-size: 0.8rem;
|
|
color: #888;
|
|
}
|
|
|
|
.course-today-plan__action {
|
|
flex-shrink: 0;
|
|
padding: 6px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--color-border-strong, #c9c2b8);
|
|
background: rgba(255, 255, 255, 0.95);
|
|
font-size: 0.82rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.course-today-plan__action:hover {
|
|
border-color: #b8a896;
|
|
background: #fff;
|
|
}
|
|
|
|
.review-badge[title] {
|
|
cursor: help;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.course-flow__grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.course-assistant {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|