Menu

Change language

GitHub repo

Flashcard Study Deck

blockeducation

Anki and Quizlet style spaced-repetition flashcard study deck with smooth 3D flip card interaction, WCAG 2.2 accessibility curriculum, hint drawer, mastery percentage tracking, keyboard shortcuts, and SM-2 interval rating buttons.

Installation

$npx uipkge-ng@latest add flashcard-study-deck
Manifest:https://uipkge.dev/r/angular/flashcard-study-deck.jsonInstalls to:src/app/components/blocks/flashcard-study-deck/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Flashcard
interface Flashcard {
  id: string
  category: string
  difficulty?: 'Level A' | 'Level AA' | 'Level AAA' | 'Design System' | 'Engineering'
  question: string
  hint: string
  answer: string
  codeSnippet?: string
  codeLanguage?: string
  source?: string
}

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--border

Files installed (2)

  • src/app/components/blocks/flashcard-study-deck/flashcard-study-deck.component.ts26.3 kB
    // Anki and Quizlet style spaced-repetition flashcard study deck with smooth 3D flip card
    // interaction, WCAG 2.2 accessibility curriculum, hint drawer, mastery percentage tracking,
    // keyboard shortcuts, and SM-2 interval rating buttons. Port of the React block 1:1.
    import {
      ChangeDetectionStrategy,
      Component,
      DestroyRef,
      Input,
      OnDestroy,
      OnInit,
      computed,
      inject,
      signal,
    } from '@angular/core'
    import {
      Bookmark,
      Check,
      ChevronLeft,
      ChevronRight,
      Copy,
      Flame,
      GraduationCap,
      Lightbulb,
      LucideAngularModule,
      RotateCw,
      Shuffle,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface Flashcard {
      id: string
      category: string
      difficulty?: 'Level A' | 'Level AA' | 'Level AAA' | 'Design System' | 'Engineering'
      question: string
      hint: string
      answer: string
      codeSnippet?: string
      codeLanguage?: string
      source?: string
    }
    
    export type RatingInterval = 'again' | 'hard' | 'good' | 'easy'
    
    const DEFAULT_DECK_TITLE = 'Design Engineering & Web Accessibility (WCAG 2.2)'
    
    const DEFAULT_CARDS: Flashcard[] = [
      {
        id: 'wcag-1-4-3',
        category: 'WCAG Criterion 1.4.3',
        difficulty: 'Level AA',
        question: 'What is the minimum contrast ratio required for regular body text under WCAG AA standards?',
        hint: 'Consider standard text (< 18pt or < 14pt bold) versus large display headings (≥ 18pt or ≥ 14pt bold).',
        answer: '4.5:1 for normal text (< 18pt or < 14pt bold) and 3:1 for large text (≥ 18pt or ≥ 14pt bold).',
        codeSnippet: 'color: oklch(0.20 0 0); /* on oklch(0.98 0 0) -> 14.2:1 passes AA/AAA */',
        codeLanguage: 'css',
        source: 'WCAG 2.2 § 1.4.3 Contrast (Minimum)',
      },
      {
        id: 'wcag-2-5-8',
        category: 'WCAG Criterion 2.5.8',
        difficulty: 'Level AA',
        question: 'What is the minimum interactive touch target size required in WCAG 2.2 Level AA?',
        hint: 'Introduced in WCAG 2.2 to prevent accidental taps on mobile touchscreens without requiring 44×44px AAA.',
        answer:
          'At least 24×24 CSS pixels, or have sufficient spacing (undersized targets surrounded by at least 24px diameter clear space).',
        codeSnippet: '<button class="min-h-6 min-w-6 p-2 touch-manipulation">',
        codeLanguage: 'html',
        source: 'WCAG 2.2 § 2.5.8 Target Size (Minimum)',
      },
      {
        id: 'wcag-2-4-7',
        category: 'WCAG Criterion 2.4.7',
        difficulty: 'Level AA',
        question: 'What visual contrast requirement applies to keyboard focus indicators under WCAG 2.2?',
        hint: 'Focus indicators must be distinguishable against both the focused element and adjacent background.',
        answer:
          'The focus indicator must have a contrast ratio of at least 3:1 against the unfocused state and surrounding background, with minimum 2px perimeter thickness.',
        codeSnippet: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
        codeLanguage: 'css',
        source: 'WCAG 2.2 § 2.4.7 & 2.4.11 Focus Appearance',
      },
      {
        id: 'wcag-4-1-2',
        category: 'WCAG Criterion 4.1.2',
        difficulty: 'Level A',
        question: 'How should custom collapsible or expandable widgets communicate state to screen readers?',
        hint: 'Use native semantic elements or explicit ARIA state attributes rather than visual classes alone.',
        answer:
          'Declare explicit ARIA roles (e.g. role="button"), aria-expanded="true|false", and aria-controls linking to the target element ID.',
        codeSnippet: '<button aria-expanded="true" aria-controls="card-back-content">',
        codeLanguage: 'html',
        source: 'WAI-ARIA 1.2 Authoring Practices',
      },
      {
        id: 'wcag-1-4-11',
        category: 'WCAG Criterion 1.4.11',
        difficulty: 'Level AA',
        question: 'What is the contrast threshold for UI components, boundaries, and graphical objects?',
        hint: 'Applies to active button borders, input outlines, checkboxes, and state badges.',
        answer:
          'A contrast ratio of at least 3:1 against adjacent colors for user interface components and graphical objects essential for comprehension.',
        codeSnippet: 'border: 1px solid var(--border); /* >= 3:1 against surface */',
        codeLanguage: 'css',
        source: 'WCAG 2.2 § 1.4.11 Non-text Contrast',
      },
      {
        id: 'oklch-tokens',
        category: 'Design Systems & Color Science',
        difficulty: 'Design System',
        question: 'Why is the OKLCH color space preferred over HSL for dark mode and accessibility contrast derivation?',
        hint: 'Perceptual lightness is uniform across all hues in OKLCH, unlike HSL where yellow looks brighter than blue at L=50%.',
        answer:
          'OKLCH is perceptually uniform. Equal changes in lightness (L) yield identical perceived contrast regardless of hue or chroma, preventing washed-out dark mode shifts.',
        codeSnippet: '--color-primary: oklch(0.62 0.22 264.4);',
        codeLanguage: 'css',
        source: 'CSS Color Module Level 4',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-flashcard-study-deck, [ui-flashcard-study-deck]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiProgressComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="flashcard-study-deck" [class]="rootClass">
          <!-- Header Section -->
          <header class="space-y-4">
            <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <div class="space-y-1">
                <div class="flex items-center gap-2">
                  <span ui-badge variant="secondary" class="gap-1 px-2.5 py-0.5 font-medium">
                    <lucide-icon [img]="GraduationCap" class="text-primary size-3.5" />
                    <span>Study Deck</span>
                  </span>
                  <span ui-badge variant="outline" class="text-muted-foreground gap-1 text-xs">
                    <lucide-icon [img]="Flame" class="text-warning size-3" />
                    <span>5-day streak</span>
                  </span>
                </div>
                <h2 class="text-foreground text-xl font-bold tracking-tight md:text-2xl">{{ deckTitle }}</h2>
              </div>
    
              <div class="flex items-center gap-2">
                <span ui-badge variant="outline" class="px-3 py-1 font-mono text-xs font-medium">
                  Card {{ currentNumber() }} of {{ totalCards() }} · {{ masteryPercentage() }}% Mastery
                </span>
              </div>
            </div>
    
            <!-- Progress Track -->
            <div class="space-y-1.5">
              <div ui-progress [value]="progressPercentage()" class="h-2"></div>
              <div class="text-muted-foreground flex items-center justify-between text-xs">
                <span>Progress: {{ currentNumber() }} / {{ totalCards() }} cards</span>
                <span>{{ reviewedCount() }} of {{ totalCards() }} reviewed</span>
              </div>
            </div>
          </header>
    
          <!-- 3D Interactive Flashcard Container -->
          @if (currentCard(); as card) {
            <div class="mx-auto w-full max-w-2xl [perspective:1200px]">
              <div [class]="flipClass">
                <!-- FRONT FACE: Question State -->
                <div ui-card [class]="frontCardClass">
                  <!-- Front Header -->
                  <div class="flex items-center justify-between gap-3">
                    <div class="flex flex-wrap items-center gap-2">
                      <span ui-badge variant="secondary" class="text-xs font-medium">{{ card.category }}</span>
                      @if (card.difficulty) {
                        <span ui-badge variant="outline" class="text-xs">{{ card.difficulty }}</span>
                      }
                    </div>
    
                    <div class="flex items-center gap-1.5">
                      <button
                        ui-button
                        variant="ghost"
                        size="icon-sm"
                        [class]="bookmarkBtnClass"
                        aria-label="Bookmark card"
                        (click)="toggleBookmark($event)"
                      >
                        <lucide-icon [img]="Bookmark" [class]="bookmarkIconClass" />
                      </button>
                    </div>
                  </div>
    
                  <!-- Question Content -->
                  <div class="my-auto space-y-4 py-4">
                    <div class="space-y-1.5">
                      <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Question Prompt
                      </span>
                      <p class="text-foreground text-lg leading-snug font-semibold tracking-tight md:text-xl">
                        {{ card.question }}
                      </p>
                    </div>
    
                    <!-- Hint Drawer / Toggle -->
                    @if (card.hint) {
                      <div class="pt-2">
                        <button
                          ui-button
                          variant="ghost"
                          size="xs"
                          class="text-muted-foreground hover:text-foreground gap-1.5 text-xs"
                          (click)="toggleHint($event)"
                        >
                          <lucide-icon [img]="Lightbulb" class="text-warning size-3.5" />
                          <span>{{ showHint() ? 'Hide Hint' : 'Show Hint' }}</span>
                          <span class="text-muted-foreground/60 font-mono">[H]</span>
                        </button>
    
                        @if (showHint()) {
                          <div
                            class="text-muted-foreground border-warning/20 bg-warning/5 mt-2.5 flex items-start gap-2 rounded-lg border p-3 text-xs leading-relaxed"
                          >
                            <lucide-icon [img]="Lightbulb" class="text-warning mt-0.5 size-4 shrink-0" />
                            <div>
                              <strong class="text-foreground font-semibold">Hint: </strong>
                              <span>{{ card.hint }}</span>
                            </div>
                          </div>
                        }
                      </div>
                    }
                  </div>
    
                  <!-- Front Footer / Flip Trigger -->
                  <div class="space-y-3 pt-2">
                    <div ui-separator></div>
                    <div class="text-muted-foreground flex flex-col items-center justify-between gap-3 text-xs sm:flex-row">
                      <span class="hidden sm:inline"
                        >Press <kbd class="bg-muted border-border rounded border px-1 py-0.5 font-mono">Space</kbd> or click
                        button</span
                      >
                      <button
                        ui-button
                        variant="default"
                        size="sm"
                        class="w-full cursor-pointer gap-2 font-medium sm:w-auto"
                        (click)="toggleFlip($event)"
                      >
                        <lucide-icon [img]="RotateCw" class="size-3.5" />
                        <span>Flip Card to Reveal Answer</span>
                      </button>
                    </div>
                  </div>
                </div>
    
                <!-- BACK FACE: Answer State -->
                <div ui-card [class]="backCardClass">
                  <!-- Back Header -->
                  <div class="flex items-center justify-between gap-3">
                    <div class="flex flex-wrap items-center gap-2">
                      <span ui-badge variant="secondary" class="text-xs font-medium">{{ card.category }}</span>
                      <span ui-badge variant="default" class="border-success/30 bg-success/15 text-success text-xs">
                        Answer Revealed
                      </span>
                    </div>
    
                    <button
                      ui-button
                      variant="ghost"
                      size="xs"
                      class="text-muted-foreground hover:text-foreground gap-1 text-xs"
                      (click)="toggleFlip($event)"
                    >
                      <lucide-icon [img]="RotateCw" class="size-3.5" />
                      <span>Flip Back</span>
                    </button>
                  </div>
    
                  <!-- Answer Content -->
                  <div class="my-auto space-y-4 overflow-y-auto py-3">
                    <div class="space-y-1.5">
                      <span class="text-primary text-xs font-semibold tracking-wider uppercase"> Answer Explanation </span>
                      <p class="text-foreground text-base leading-relaxed font-medium md:text-lg">{{ card.answer }}</p>
                    </div>
    
                    <!-- Code / Token Example Block -->
                    @if (card.codeSnippet) {
                      <div class="border-border bg-muted/60 dark:bg-muted/30 space-y-2 rounded-lg border p-3.5">
                        <div class="text-muted-foreground flex items-center justify-between text-xs">
                          <span class="font-mono font-medium">Code / Token Example</span>
                          <button
                            ui-button
                            variant="ghost"
                            size="xs"
                            class="h-6 gap-1 px-2 text-xs"
                            (click)="copyCodeSnippet($event)"
                          >
                            @if (copiedSnippet()) {
                              <lucide-icon [img]="Check" class="text-success size-3" />
                            } @else {
                              <lucide-icon [img]="Copy" class="size-3" />
                            }
                            <span>{{ copiedSnippet() ? 'Copied' : 'Copy' }}</span>
                          </button>
                        </div>
                        <pre
                          class="text-foreground bg-background/80 border-border/60 overflow-x-auto rounded border p-2 font-mono text-xs md:text-sm"
                        ><code>{{ card.codeSnippet }}</code></pre>
                        @if (card.source) {
                          <p class="text-muted-foreground text-xs italic">Source: {{ card.source }}</p>
                        }
                      </div>
                    }
                  </div>
    
                  <!-- Spaced-Repetition Feedback Buttons -->
                  <div class="space-y-2.5 pt-2">
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span class="font-medium">Rate recall difficulty (SM-2 Spaced Repetition):</span>
                      @if (lastRatingFeedback()) {
                        <span class="text-primary text-xs font-medium">{{ lastRatingFeedback() }}</span>
                      }
                    </div>
    
                    <div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20 text-destructive flex h-auto flex-col py-2 text-xs active:scale-95"
                        (click)="handleRate('again', $event)"
                      >
                        <span class="font-semibold">Again</span>
                        <span class="text-xs opacity-80">&lt; 1 min [1]</span>
                      </button>
    
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 text-warning flex h-auto flex-col py-2 text-xs active:scale-95"
                        (click)="handleRate('hard', $event)"
                      >
                        <span class="font-semibold">Hard</span>
                        <span class="text-xs opacity-80">12 hours [2]</span>
                      </button>
    
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="border-info/30 bg-info/10 text-info hover:bg-info/20 text-info flex h-auto flex-col py-2 text-xs active:scale-95"
                        (click)="handleRate('good', $event)"
                      >
                        <span class="font-semibold">Good</span>
                        <span class="text-xs opacity-80">2 days [3]</span>
                      </button>
    
                      <button
                        ui-button
                        variant="outline"
                        size="sm"
                        class="border-success/30 bg-success/10 text-success hover:bg-success/20 text-success flex h-auto flex-col py-2 text-xs active:scale-95"
                        (click)="handleRate('easy', $event)"
                      >
                        <span class="font-semibold">Easy</span>
                        <span class="text-xs opacity-80">4 days [4]</span>
                      </button>
                    </div>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Deck Navigation & Utility Controls -->
            <div class="mx-auto flex max-w-2xl flex-col items-center justify-between gap-3 pt-2 sm:flex-row">
              <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  [disabled]="currentIndex() === 0"
                  class="gap-1.5"
                  (click)="handlePrev()"
                >
                  <lucide-icon [img]="ChevronLeft" class="size-4" />
                  <span>Previous</span>
                </button>
    
                <button ui-button variant="outline" size="sm" class="gap-1.5" (click)="handleShuffle()">
                  <lucide-icon [img]="Shuffle" class="size-3.5" />
                  <span>Shuffle</span>
                </button>
              </div>
    
              <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end">
                <button ui-button variant="secondary" size="sm" class="gap-1.5" (click)="toggleFlip($event)">
                  <lucide-icon [img]="RotateCw" class="size-3.5" />
                  <span>{{ isFlipped() ? 'Show Question' : 'Flip Card' }}</span>
                </button>
    
                <button
                  ui-button
                  variant="default"
                  size="sm"
                  [disabled]="currentIndex() === totalCards() - 1"
                  class="gap-1.5"
                  (click)="handleNext()"
                >
                  <span>Next</span>
                  <lucide-icon [img]="ChevronRight" class="size-4" />
                </button>
              </div>
            </div>
    
            <!-- Keyboard Shortcuts Legend Footer -->
            <div class="mx-auto max-w-2xl pt-2 text-center">
              <p class="text-muted-foreground font-mono text-xs">
                Shortcuts: <span class="text-foreground font-semibold">← / →</span> Navigate ·
                <span class="text-foreground font-semibold">Space</span> Flip ·
                <span class="text-foreground font-semibold">H</span> Hint ·
                <span class="text-foreground font-semibold">1-4</span> Rate Interval
              </p>
            </div>
          }
        </div>
      `,
    })
    export class UiFlashcardStudyDeckComponent implements OnInit, OnDestroy {
      protected readonly Bookmark = Bookmark
      protected readonly Check = Check
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly Copy = Copy
      protected readonly Flame = Flame
      protected readonly GraduationCap = GraduationCap
      protected readonly Lightbulb = Lightbulb
      protected readonly RotateCw = RotateCw
      protected readonly Shuffle = Shuffle
    
      @Input() initialTitle?: string
      @Input() initialCards?: Flashcard[]
      @Input('class') className?: string
    
      readonly deck = signal<Flashcard[]>(DEFAULT_CARDS.map((c) => ({ ...c })))
      readonly currentIndex = signal(0)
      readonly isFlipped = signal(false)
      readonly showHint = signal(false)
      readonly copiedSnippet = signal(false)
      readonly bookmarkedCards = signal<Set<string>>(new Set())
      readonly cardScores = signal<Record<string, number>>({})
      readonly lastRatingFeedback = signal<string | null>(null)
    
      private timers: ReturnType<typeof setTimeout>[] = []
      private onKeyDown = (event: KeyboardEvent): void => this.handleKeyDown(event)
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          for (const t of this.timers) clearTimeout(t)
          if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeyDown)
        })
      }
    
      ngOnInit(): void {
        if (this.initialCards) this.deck.set(this.initialCards.map((c) => ({ ...c })))
        if (typeof window !== 'undefined') window.addEventListener('keydown', this.onKeyDown)
      }
    
      ngOnDestroy(): void {
        if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeyDown)
      }
    
      readonly currentCard = computed(() => this.deck()[this.currentIndex()] ?? this.deck()[0])
      readonly totalCards = computed(() => this.deck().length)
      readonly currentNumber = computed(() => this.currentIndex() + 1)
      readonly progressPercentage = computed(() => Math.round((this.currentNumber() / this.totalCards()) * 100))
      readonly reviewedCount = computed(() => Object.keys(this.cardScores()).length)
    
      readonly masteryPercentage = computed(() => {
        const keys = Object.keys(this.cardScores())
        if (keys.length === 0) return 0
        const total = keys.reduce((sum, key) => sum + (this.cardScores()[key] ?? 0), 0)
        return Math.min(100, Math.round(total / this.totalCards()))
      })
    
      readonly isBookmarked = computed(() => {
        const card = this.currentCard()
        return card ? this.bookmarkedCards().has(card.id) : false
      })
    
      get deckTitle(): string {
        return this.initialTitle ?? DEFAULT_DECK_TITLE
      }
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-4xl space-y-6 p-4 md:p-6 lg:p-8', this.className)
      }
    
      get flipClass(): string {
        return cn(
          'relative min-h-[440px] w-full rounded-xl transition-transform duration-500 [transform-style:preserve-3d] md:min-h-[460px]',
          this.isFlipped() && '[transform:rotateY(180deg)]',
        )
      }
    
      get frontCardClass(): string {
        return cn(
          'border-border bg-card absolute inset-0 flex h-full w-full flex-col justify-between rounded-xl border p-6 shadow-xs [backface-visibility:hidden] md:p-8',
          this.isFlipped() ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100',
        )
      }
    
      get backCardClass(): string {
        return cn(
          'border-border bg-card absolute inset-0 flex h-full w-full [transform:rotateY(180deg)] flex-col justify-between rounded-xl border p-6 shadow-xs [backface-visibility:hidden] md:p-8',
          this.isFlipped() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
        )
      }
    
      get bookmarkBtnClass(): string {
        return cn('text-muted-foreground hover:text-foreground', this.isBookmarked() && 'text-warning')
      }
    
      get bookmarkIconClass(): string {
        return cn('size-4', this.isBookmarked() && 'fill-warning text-warning')
      }
    
      toggleFlip(event?: Event): void {
        event?.stopPropagation()
        this.isFlipped.update((v) => !v)
      }
    
      toggleHint(event?: Event): void {
        event?.stopPropagation()
        this.showHint.update((v) => !v)
      }
    
      toggleBookmark(event?: Event): void {
        event?.stopPropagation()
        const card = this.currentCard()
        if (!card) return
        this.bookmarkedCards.update((prev) => {
          const next = new Set(prev)
          if (next.has(card.id)) next.delete(card.id)
          else next.add(card.id)
          return next
        })
      }
    
      handlePrev(): void {
        if (this.currentIndex() > 0) {
          this.currentIndex.update((i) => i - 1)
          this.isFlipped.set(false)
          this.showHint.set(false)
          this.lastRatingFeedback.set(null)
        }
      }
    
      handleNext(): void {
        if (this.currentIndex() < this.totalCards() - 1) {
          this.currentIndex.update((i) => i + 1)
          this.isFlipped.set(false)
          this.showHint.set(false)
          this.lastRatingFeedback.set(null)
        }
      }
    
      handleShuffle(): void {
        const shuffled = [...this.deck()]
        for (let i = shuffled.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1))
          ;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
        }
        this.deck.set(shuffled)
        this.currentIndex.set(0)
        this.isFlipped.set(false)
        this.showHint.set(false)
        this.lastRatingFeedback.set('Deck shuffled')
        this.timers.push(
          setTimeout(() => {
            if (this.lastRatingFeedback() === 'Deck shuffled') this.lastRatingFeedback.set(null)
          }, 2000),
        )
      }
    
      handleRate(interval: RatingInterval, event?: Event): void {
        event?.stopPropagation()
        const card = this.currentCard()
        if (!card) return
        const scoreMap: Record<RatingInterval, { score: number; label: string }> = {
          again: { score: 25, label: 'Again (< 1 min)' },
          hard: { score: 50, label: 'Hard (12 hours)' },
          good: { score: 80, label: 'Good (2 days)' },
          easy: { score: 100, label: 'Easy (4 days)' },
        }
        const { score, label } = scoreMap[interval]
        this.cardScores.update((prev) => ({ ...prev, [card.id]: score }))
        this.lastRatingFeedback.set(`Rated: ${label}`)
        this.timers.push(
          setTimeout(() => {
            if (this.currentIndex() < this.totalCards() - 1) this.handleNext()
            else {
              this.isFlipped.set(false)
              this.showHint.set(false)
            }
          }, 350),
        )
      }
    
      async copyCodeSnippet(event?: Event): Promise<void> {
        event?.stopPropagation()
        const snippet = this.currentCard()?.codeSnippet
        if (!snippet) return
        try {
          await navigator.clipboard.writeText(snippet)
          this.copiedSnippet.set(true)
          this.timers.push(setTimeout(() => this.copiedSnippet.set(false), 2000))
        } catch {
          // Clipboard permission fallback
        }
      }
    
      private handleKeyDown(event: KeyboardEvent): void {
        const target = event.target as HTMLElement | null
        if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
        if (event.key === 'ArrowLeft') {
          event.preventDefault()
          this.handlePrev()
        } else if (event.key === 'ArrowRight') {
          event.preventDefault()
          this.handleNext()
        } else if (event.key === ' ' || event.code === 'Space') {
          event.preventDefault()
          this.toggleFlip()
        } else if (event.key.toLowerCase() === 'h') {
          event.preventDefault()
          this.toggleHint()
        } else if (this.isFlipped()) {
          if (event.key === '1') {
            event.preventDefault()
            this.handleRate('again')
          } else if (event.key === '2') {
            event.preventDefault()
            this.handleRate('hard')
          } else if (event.key === '3') {
            event.preventDefault()
            this.handleRate('good')
          } else if (event.key === '4') {
            event.preventDefault()
            this.handleRate('easy')
          }
        }
      }
    }
    
  • src/app/components/blocks/flashcard-study-deck/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/flashcard-study-deck.json