All checks were successful
Deploy to production / deploy (push) Successful in 2m6s
- 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.
1785 lines
54 KiB
Vue
1785 lines
54 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>{{ 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>
|