Files
yourpart3/frontend/src/views/social/VocabCourseView.vue
Torsten Schulz (local) d854200708
All checks were successful
Deploy to production / deploy (push) Successful in 2m6s
feat(VocabPracticeDialog, VocabCourseView): enhance hard vocabulary management and UI
- Refactored methods in VocabPracticeDialog to improve the handling of hard vocabulary items, including the addition of `findMatchingHardKey` and `removeHardEntriesForItem` for better management of hard vocabulary entries.
- Updated the VocabCourseView to display a new section for hard vocabulary items, allowing users to view and remove difficult words easily.
- Enhanced the UI with new styles for the hard vocabulary list, improving user engagement and accessibility to challenging vocabulary practice.
2026-04-26 23:32:18 +02:00

1785 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>{{ displayCourseTitle(course) }}</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>
<button
v-if="hardVocabCount > 0"
type="button"
class="course-hard-practice-link"
@click="openHardPractice"
>
{{ $t('socialnetwork.vocab.courses.startHardVocabTrainer', { count: hardVocabCount }) }}
</button>
<button type="button" class="course-dictionary-link" @click="goCourseDictionary">
{{ $t('socialnetwork.vocab.dictionary.open') }}
</button>
</div>
<section v-if="hardVocabCount > 0" class="surface-card course-hard-list">
<div class="course-hard-list__header">
<h3>{{ $t('socialnetwork.vocab.courses.startHardVocabTrainer', { count: hardVocabCount }) }}</h3>
<button
type="button"
class="button-secondary"
@click="openHardPractice"
>
{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}
</button>
</div>
<div class="course-hard-list__items">
<div v-for="entry in hardVocabList" :key="entry.key" class="course-hard-list__item">
<div class="course-hard-list__texts">
<strong>{{ entry.learning }}</strong>
<span>{{ entry.reference }}</span>
</div>
<button
type="button"
class="btn-delete"
@click="removeHardVocabEntry(entry.key)"
>
{{ $t('socialnetwork.vocab.courses.unmarkVocabHard') }}
</button>
</div>
</div>
</section>
<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.srsDueStat', { count: srsDueCount }) }}</span>
<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="srsDueCount > 0" class="course-srs-plan">
<div>
<span class="course-srs-plan__eyebrow">{{ $t('socialnetwork.vocab.courses.srsEyebrow') }}</span>
<h4>{{ $t('socialnetwork.vocab.courses.srsTitle', { count: srsDueCount }) }}</h4>
<p>{{ $t('socialnetwork.vocab.courses.srsIntro') }}</p>
</div>
<button type="button" class="course-today-plan__action" :disabled="srsLoading" @click="openSrsPractice">
{{ srsLoading ? $t('general.loading') : $t('socialnetwork.vocab.courses.srsStart') }}
</button>
</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">
{{ srsDueCount > 0
? $t('socialnetwork.vocab.courses.courseTodayPlanIntroSrs')
: 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-if="showTodayPlanSoftOptional" class="course-today-plan course-today-plan--soft">
<h4 class="course-today-plan__title">{{ $t('socialnetwork.vocab.courses.courseTodayPlanSoftTitle') }}</h4>
<p class="course-today-plan__intro">{{ $t('socialnetwork.vocab.courses.courseTodayPlanSoftIntro') }}</p>
<ol class="course-today-plan__list">
<li
v-for="(step, idx) in todayPlanOptionalSteps"
:key="`opt-${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 course-today-plan__action--soft" @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';
import { localizeVocabCourseTitle } from '@/utils/vocabCourseTitle.js';
export default {
name: 'VocabCourseView',
components: { VocabPracticeDialog },
props: {
courseId: {
type: String,
required: true
}
},
data() {
return {
loading: false,
course: null,
progress: [],
chapters: [],
srsDueItems: [],
srsDueTotal: 0,
srsLoading: false,
showAddLessonDialog: false,
assistantSettings: null,
hardVocabList: [],
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;
},
srsDueCount() {
if (Number.isFinite(Number(this.srsDueTotal)) && Number(this.srsDueTotal) > 0) {
return Number(this.srsDueTotal);
}
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
},
hardVocabCount() {
return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0;
},
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);
},
/**
* Didaktischer Tagesvorschlag (nicht Kalender-fixiert, aber nach Prinzipien):
* 1) Spacing: fällige Kurz-Wiederholungen zuerst (Abruf vor neuem Input).
* 2) Kognitive Last: nur eine begrenzte „Einheit“ neuer Block-Lektionen pro Aufruf,
* schwere/checkpoint/intensive Lektionen zählen stärker (siehe pickPedagogicalBlockSteps).
* 3) Dann Intensivphase, wenn freigeschaltet.
* Keine „Fallbacks“ in dieser Liste: weitere Lektion/Trainer nur unter
* todayPlanOptionalSteps mit eigener Einleitung (Pause/Spacing sinnvoller als Pflicht).
*/
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.pickPedagogicalBlockSteps().forEach((l) => push('block', l));
if (this.nextIntensiveReviewLesson) {
push('intensive', this.nextIntensiveReviewLesson);
}
return out.slice(0, 8);
},
/** Optional: nur wenn kein didaktischer Pflichtplan semantisch „könntest du, Pause oft besser“. */
todayPlanOptionalSteps() {
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 });
};
if (this.currentLesson) {
const p = this.getLessonProgress(this.currentLesson.id, this.currentLesson);
if (!p?.completed) {
push('continue', this.currentLesson);
}
}
if (this.freePracticeLessons.length > 0) {
push('practice', this.freePracticeLessons[0]);
}
return out;
},
showTodayPlanSoftOptional() {
return this.todayRecommendedSteps.length === 0 && this.todayPlanOptionalSteps.length > 0;
},
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();
this.refreshHardVocabList();
}
},
methods: {
displayCourseTitle(course) {
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
},
hardStorageKey() {
return this.courseId ? `yourpart:vocab:hardList:${this.courseId}` : null;
},
refreshHardVocabList() {
const key = this.hardStorageKey();
if (!key) {
this.hardVocabList = [];
return;
}
try {
const raw = localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : {};
const values = parsed && typeof parsed === 'object' ? Object.entries(parsed) : [];
this.hardVocabList = values
.map(([entryKey, entry], idx) => {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!learning || !reference) return null;
return {
id: `hard-${idx}-${learning}-${reference}`,
key: String(entryKey || ''),
learning,
reference
};
})
.filter(Boolean);
} catch (_) {
this.hardVocabList = [];
}
},
removeHardVocabEntry(entryKey) {
const key = this.hardStorageKey();
if (!key || !entryKey) return;
try {
const raw = localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== 'object' || !parsed[entryKey]) return;
const next = { ...parsed };
delete next[entryKey];
localStorage.setItem(key, JSON.stringify(next));
this.refreshHardVocabList();
} catch (_) {
// ignore storage parse/write errors
}
},
handleWindowFocus() {
this.refreshHardVocabList();
},
async loadCourse() {
this.loading = true;
try {
const res = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
this.course = res.data;
await this.loadProgress();
await this.loadSrsDueItems();
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 loadSrsDueItems() {
this.srsLoading = true;
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/srs/due`, {
params: { limit: 40 }
});
this.srsDueItems = Array.isArray(data?.items) ? data.items : [];
this.srsDueTotal = Number.isFinite(Number(data?.totalDueCount)) ? Number(data.totalDueCount) : this.srsDueItems.length;
} catch (e) {
console.warn('Konnte SRS-Fälligkeiten nicht laden:', e);
this.srsDueItems = [];
this.srsDueTotal = 0;
} finally {
this.srsLoading = false;
}
},
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 || '';
},
/**
* Grobe „Last-Einheit“ pro Lektion für Tageskappung (1 = leicht, 2 = schwer/Checkpoint/Intensiv im Block).
*/
blockLessonDailyLoadUnits(lesson) {
if (!lesson) {
return 1;
}
const p = lesson.pedagogy || {};
const mode = p.didacticMode;
if (mode === 'checkpoint' || mode === 'intensive_review' || p.isIntensiveReview) {
return 2;
}
const w = Number(p.difficultyWeight);
if (Number.isFinite(w) && w >= 4) {
return 2;
}
return 1;
},
/**
* Offene Lektionen im Block nach Nummer, mit Obergrenze für „neues Material“ pro Empfehlungsliste
* (Spacing + cognitive load: nicht drei schwere Lektionen auf einmal).
*/
pickPedagogicalBlockSteps() {
const list = [...this.currentBlockLessons].sort(
(a, b) => Number(a.lessonNumber) - Number(b.lessonNumber)
);
const MAX_UNITS = 3;
const picked = [];
let units = 0;
for (const l of list) {
const cost = this.blockLessonDailyLoadUnits(l);
if (units + cost > MAX_UNITS) {
break;
}
picked.push(l);
units += cost;
}
return picked;
},
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'));
}
},
goCourseDictionary() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/dictionary`);
},
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
});
},
openHardPractice() {
if (!this.hardVocabCount) return;
this.$refs.practiceDialog?.open?.({
courseId: this.courseId,
initialPool: this.hardVocabList,
onClose: () => this.refreshHardVocabList()
});
},
openSrsPractice() {
if (!this.srsDueItems.length) {
return;
}
this.$refs.practiceDialog?.open?.({
courseId: this.courseId,
initialPool: this.srsDueItems,
srsMode: true,
onClose: () => this.loadSrsDueItems()
});
},
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()
]);
this.refreshHardVocabList();
window.addEventListener('focus', this.handleWindowFocus);
},
beforeUnmount() {
window.removeEventListener('focus', this.handleWindowFocus);
},
};
</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;
align-items: center;
}
.course-dictionary-link {
margin-left: auto;
}
.course-hard-practice-link {
flex-shrink: 0;
}
.course-hard-list {
padding: 16px 18px;
}
.course-hard-list__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.course-hard-list__header h3 {
margin: 0;
font-size: 1rem;
}
.course-hard-list__items {
display: grid;
gap: 8px;
}
.course-hard-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--color-border, #e1dbd4);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
}
.course-hard-list__texts {
display: grid;
gap: 2px;
}
.course-hard-list__texts span {
color: var(--color-text-secondary, #6b625b);
}
.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-srs-plan {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 16px 18px;
border-radius: 14px;
border: 1px solid rgba(102, 153, 126, 0.38);
background: linear-gradient(135deg, rgba(232, 247, 238, 0.95), rgba(255, 251, 240, 0.8));
}
.course-srs-plan__eyebrow {
display: inline-flex;
margin-bottom: 4px;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #4f7b60;
}
.course-srs-plan h4,
.course-srs-plan p {
margin: 0;
}
.course-srs-plan p {
margin-top: 6px;
color: var(--color-text-secondary, #5c534c);
font-size: 0.88rem;
line-height: 1.45;
}
.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--soft {
border-style: dashed;
opacity: 0.95;
}
.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--soft {
font-weight: normal;
}
.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>