-
Notifications
You must be signed in to change notification settings - Fork 0
feat: address all remaining TODOs #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||||||||
| "use client"; | ||||||||||||||||
|
|
||||||||||||||||
| import { useQuery } from "convex/react"; | ||||||||||||||||
| import { useParams } from "next/navigation"; | ||||||||||||||||
| import { useEffect } from "react"; | ||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||
| import ReactMarkdown from "react-markdown"; | ||||||||||||||||
| import remarkGfm from "remark-gfm"; | ||||||||||||||||
| import { api } from "@convex/_generated/api"; | ||||||||||||||||
| import { useAuthStore } from "@/store/authStore"; | ||||||||||||||||
| import type { Id } from "@convex/_generated/dataModel"; | ||||||||||||||||
|
|
||||||||||||||||
| export default function HandoutPrintPage() { | ||||||||||||||||
| const params = useParams(); | ||||||||||||||||
| const handoutId = params.id as string; | ||||||||||||||||
| const { token } = useAuthStore(); | ||||||||||||||||
|
|
||||||||||||||||
| const data = useQuery( | ||||||||||||||||
| api.handouts.getHandoutWithBlocks, | ||||||||||||||||
| token ? { token, handoutId: handoutId as Id<"handouts"> } : "skip" | ||||||||||||||||
| ); | ||||||||||||||||
|
Comment on lines
+18
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle the skipped-query path separately from the loading state. Lines 18-21 pass Also applies to: 40-41 🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| // Print-Dialog automatisch öffnen wenn Daten geladen | ||||||||||||||||
| useEffect(() => { | ||||||||||||||||
| if (data) { | ||||||||||||||||
| const timer = setTimeout(() => window.print(), 300); | ||||||||||||||||
| return () => clearTimeout(timer); | ||||||||||||||||
| } | ||||||||||||||||
| }, [!!data]); | ||||||||||||||||
|
Comment on lines
+24
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The dependency array The idiomatic pattern that achieves the same intent and satisfies the linter:
Suggested change
The |
||||||||||||||||
|
|
||||||||||||||||
| const revealLabel = (rule: any): string => { | ||||||||||||||||
| if (rule.alwaysVisible) return "Immer sichtbar"; | ||||||||||||||||
| if (rule.manuallyTriggered) return "Manuell"; | ||||||||||||||||
| let label = `Ab Folie ${rule.revealSlide}`; | ||||||||||||||||
| if (rule.revealToSlide) label += `–${rule.revealToSlide}`; | ||||||||||||||||
| if (rule.relockOnBack) label += " ↩"; | ||||||||||||||||
| return label; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| if (!data) { | ||||||||||||||||
| return <div className="p-8 text-gray-500">Lädt...</div>; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const blocks = [...data.blocks].sort((a, b) => a.order - b.order); | ||||||||||||||||
|
|
||||||||||||||||
| return ( | ||||||||||||||||
| <div className="min-h-screen bg-white"> | ||||||||||||||||
| <style>{` | ||||||||||||||||
| @media print { | ||||||||||||||||
| .no-print { display: none !important; } | ||||||||||||||||
| body { font-size: 11pt; } | ||||||||||||||||
| .handout-block { break-inside: avoid; page-break-inside: avoid; } | ||||||||||||||||
| } | ||||||||||||||||
| `}</style> | ||||||||||||||||
|
|
||||||||||||||||
| {/* Toolbar – wird nicht gedruckt */} | ||||||||||||||||
| <div className="no-print sticky top-0 bg-gray-50 border-b border-gray-200 px-6 py-3 flex items-center justify-between"> | ||||||||||||||||
| <div className="flex items-center gap-3"> | ||||||||||||||||
| <Link | ||||||||||||||||
| href={`/dashboard/handout/${handoutId}`} | ||||||||||||||||
| className="text-sm text-gray-500 hover:text-gray-700" | ||||||||||||||||
| > | ||||||||||||||||
| ← Zurück | ||||||||||||||||
| </Link> | ||||||||||||||||
| <span className="text-gray-300">|</span> | ||||||||||||||||
| <span className="text-sm font-medium text-gray-700">Druckvorschau – alle Blöcke</span> | ||||||||||||||||
| </div> | ||||||||||||||||
| <button | ||||||||||||||||
| onClick={() => window.print()} | ||||||||||||||||
| className="btn-primary text-sm" | ||||||||||||||||
| > | ||||||||||||||||
| Drucken / Als PDF speichern | ||||||||||||||||
| </button> | ||||||||||||||||
| </div> | ||||||||||||||||
|
|
||||||||||||||||
| {/* Druckinhalt */} | ||||||||||||||||
| <div className="max-w-2xl mx-auto px-6 py-8"> | ||||||||||||||||
| <div className="mb-8 pb-4 border-b border-gray-300"> | ||||||||||||||||
| <h1 className="text-2xl font-bold text-gray-900">{data.title}</h1> | ||||||||||||||||
| {data.description && ( | ||||||||||||||||
| <p className="text-gray-600 mt-1">{data.description}</p> | ||||||||||||||||
| )} | ||||||||||||||||
| <p className="text-xs text-gray-400 mt-2 no-print"> | ||||||||||||||||
| {blocks.length} Blöcke · Reveal-Regeln sind als Badge sichtbar · Reihenfolge wie im Editor | ||||||||||||||||
| </p> | ||||||||||||||||
| </div> | ||||||||||||||||
|
|
||||||||||||||||
| <div className="space-y-6"> | ||||||||||||||||
| {blocks.map((block, idx) => ( | ||||||||||||||||
| <div key={block._id} className="handout-block"> | ||||||||||||||||
| <div className="flex items-baseline gap-2 mb-2"> | ||||||||||||||||
| <span className="text-xs text-gray-400 no-print">#{idx + 1}</span> | ||||||||||||||||
| <h2 className="text-base font-semibold text-gray-900">{block.title}</h2> | ||||||||||||||||
| <span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 rounded px-1.5 py-0.5"> | ||||||||||||||||
| {revealLabel(block.revealRule)} | ||||||||||||||||
| </span> | ||||||||||||||||
| </div> | ||||||||||||||||
| <div className="prose prose-sm max-w-none markdown-content text-gray-700"> | ||||||||||||||||
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{block.content}</ReactMarkdown> | ||||||||||||||||
| </div> | ||||||||||||||||
| {idx < blocks.length - 1 && ( | ||||||||||||||||
| <hr className="mt-6 border-gray-200" /> | ||||||||||||||||
| )} | ||||||||||||||||
| </div> | ||||||||||||||||
| ))} | ||||||||||||||||
| </div> | ||||||||||||||||
|
|
||||||||||||||||
| <div className="mt-8 pt-4 border-t border-gray-200 text-xs text-gray-400 no-print"> | ||||||||||||||||
| Slide Handout · {new Date().toLocaleDateString("de-DE")} | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| </div> | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,18 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import { useQuery } from "convex/react"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { useQuery, useMutation } from "convex/react"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { useParams } from "next/navigation"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import ReactMarkdown from "react-markdown"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import remarkGfm from "remark-gfm"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { api } from "@convex/_generated/api"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /** Stabile Viewer-ID für diese Browser-Session (nicht persistent über Tabs) */ | ||||||||||||||||||||||||||||||||||||||||||||||||
| function getViewerId(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const key = "slide-handout-viewer-id"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| let id = sessionStorage.getItem(key); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!id) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| id = Math.random().toString(36).slice(2) + Date.now().toString(36); | ||||||||||||||||||||||||||||||||||||||||||||||||
| sessionStorage.setItem(key, id); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| return id; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export default function PublicHandoutPage() { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const params = useParams(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const publicToken = params.token as string; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const sessionInfo = useQuery(api.sessions.getPublicSession, { publicToken }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const visibleBlocks = useQuery(api.sessions.getVisibleBlocksForPublic, { publicToken }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const pingViewer = useMutation(api.viewers.pingViewer); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Viewer-Heartbeat: beim Laden und danach alle 30s | ||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!sessionInfo || sessionInfo.status === "ended") return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const viewerId = getViewerId(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {}); | ||||||||||||||||||||||||||||||||||||||||||||||||
| ping(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const interval = setInterval(ping, 30_000); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return () => clearInterval(interval); | ||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, [publicToken, sessionInfo?.status, pingViewer]); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+29
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n apps/web/src/app/h/[token]/page.tsx | head -60Repository: EinfachMxrc/handout Length of output: 2648 🏁 Script executed: rg "sessionInfo" apps/web/src/app/h/[token]/page.tsx -B 2 -A 2Repository: EinfachMxrc/handout Length of output: 2458 🏁 Script executed: rg "useQuery\|sessionInfo" apps/web/src -A 3 | head -100Repository: EinfachMxrc/handout Length of output: 45 🏁 Script executed: fd "schema\|type" apps/web/src --type f | grep -i session | head -10Repository: EinfachMxrc/handout Length of output: 45 Distinguish The interval starts while Suggested fix+ const heartbeatState =
+ sessionInfo === undefined ? "loading" : sessionInfo === null ? "missing" : sessionInfo.status;
+
// Viewer-Heartbeat: beim Laden und danach alle 30s
useEffect(() => {
- if (sessionInfo?.status === "ended") return;
+ if (heartbeatState !== "draft" && heartbeatState !== "live") return;
const viewerId = getViewerId();
const ping = () => pingViewer({ publicToken, viewerId }).catch(() => {});
ping();
const interval = setInterval(ping, 30_000);
return () => clearInterval(interval);
- }, [publicToken, sessionInfo?.status, pingViewer]);
+ }, [publicToken, heartbeatState, pingViewer]);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // useRef instead of useState to avoid stale-closure bugs in Strict Mode: | ||||||||||||||||||||||||||||||||||||||||||||||||
| // mutations to prevBlockIds are synchronous and don't trigger extra renders. | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,20 +19,88 @@ export function generateToken(length: number = 12): string { | |
| } | ||
|
|
||
| /** | ||
| * Simple hash for MVP auth – NOT for production use. | ||
| * Centralised here so auth.ts and seed.ts always use the same implementation. | ||
| * SHA-256 password hash using the Web Crypto API. | ||
| * Kept for verifying legacy accounts created before PBKDF2 migration. | ||
| * Do NOT use for new accounts — use pbkdf2Hash instead. | ||
| * Hash format: "sha256_<hex>" | ||
| */ | ||
| export async function sha256Hash(password: string): Promise<string> { | ||
| const salted = `slide-handout:${password}`; | ||
| const data = new TextEncoder().encode(salted); | ||
| const buffer = await crypto.subtle.digest("SHA-256", data); | ||
| const hex = Array.from(new Uint8Array(buffer)) | ||
| .map((b) => b.toString(16).padStart(2, "0")) | ||
| .join(""); | ||
| return `sha256_${hex}`; | ||
|
Comment on lines
+27
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A secure implementation requires a random per-user salt stored alongside the hash. At minimum, the schema would need a // Generate on register/upgrade:
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
const salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('');
const hash = await sha256Hash(password, salt);
// Store both `hash` and `salt` in the presenters row.
export async function sha256Hash(password: string, salt: string): Promise<string> {
const data = new TextEncoder().encode(`slide-handout:${salt}:${password}`);
const buffer = await crypto.subtle.digest('SHA-256', data);
const hex = Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join('');
return `sha256_${hex}`;
}Alternatively, |
||
| } | ||
|
|
||
| /** | ||
| * PBKDF2-SHA256 password hash with a random per-user salt. | ||
| * Uses Web Crypto API available in Convex's V8 isolate. | ||
| * Hash format: "pbkdf2_<saltHex>_<hashHex>" | ||
| * | ||
| * NOTE: This is a 32-bit integer hash, not bcrypt. It is intentionally | ||
| * lightweight for an MVP and must be replaced with a proper password-hashing | ||
| * library (e.g. bcrypt via a Convex Action) before production deployment. | ||
| * @param password Plaintext password | ||
| * @param saltHex Optional 32-char hex salt (re-derive for verification); if | ||
| * omitted a fresh random 16-byte salt is generated. | ||
| */ | ||
| export async function pbkdf2Hash(password: string, saltHex?: string): Promise<string> { | ||
| const saltBytes = saltHex | ||
| ? new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16))) | ||
| : crypto.getRandomValues(new Uint8Array(16)); | ||
| const saltHexOut = Array.from(saltBytes) | ||
| .map((b) => b.toString(16).padStart(2, "0")) | ||
| .join(""); | ||
|
|
||
| const key = await crypto.subtle.importKey( | ||
| "raw", | ||
| new TextEncoder().encode(password), | ||
| "PBKDF2", | ||
| false, | ||
| ["deriveBits"] | ||
| ); | ||
| const bits = await crypto.subtle.deriveBits( | ||
| { name: "PBKDF2", hash: "SHA-256", salt: saltBytes, iterations: 100_000 }, | ||
| key, | ||
| 256 | ||
| ); | ||
| const hashHex = Array.from(new Uint8Array(bits)) | ||
| .map((b) => b.toString(16).padStart(2, "0")) | ||
| .join(""); | ||
| return `pbkdf2_${saltHexOut}_${hashHex}`; | ||
| } | ||
|
|
||
| /** | ||
| * Verify a password against any stored hash format (pbkdf2_, sha256_, mvp_). | ||
| * Returns true if the password matches. | ||
| */ | ||
| export async function verifyPassword(password: string, stored: string): Promise<boolean> { | ||
| if (stored.startsWith("pbkdf2_")) { | ||
| const parts = stored.split("_"); | ||
| if (parts.length !== 3) return false; | ||
| const expected = await pbkdf2Hash(password, parts[1]); | ||
| return expected === stored; | ||
| } | ||
| if (stored.startsWith("sha256_")) { | ||
| return (await sha256Hash(password)) === stored; | ||
| } | ||
| if (stored.startsWith("mvp_")) { | ||
| return simpleHash(password) === stored; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Legacy 32-bit hash (MVP only). Kept for migration: existing accounts | ||
| * that still have an mvp_-prefixed hash are upgraded to SHA-256 on next login. | ||
| * Do NOT use for new accounts. | ||
| */ | ||
| export function simpleHash(password: string): string { | ||
| let hash = 0; | ||
| const salted = `slide-handout-mvp:${password}`; | ||
| for (let i = 0; i < salted.length; i++) { | ||
| const char = salted.charCodeAt(i); | ||
| hash = (hash << 5) - hash + char; | ||
| hash = hash & hash; // 32-bit truncation | ||
| hash = hash & hash; | ||
| } | ||
| return `mvp_${Math.abs(hash).toString(16)}`; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix the mismatched quotes in the panel name.
„Add-in verbinden"-Panelmixes German opening quotes with an ASCII closing quote. Use„Add-in verbinden“-Panel(or drop the quotes) so the README renders cleanly.🧰 Tools
🪛 LanguageTool
[typographical] ~244-~244: Zeichen ohne sein Gegenstück: ‚“‘ scheint zu fehlen
Context: ...griert - [x] Add-in Token-Übergabe: „Add-in verbinden"-Panel in der Session-S...
(DE_UNPAIRED_QUOTES)
[typographical] ~244-~244: Zeichen ohne sein Gegenstück: ‚"‘ scheint zu fehlen
Context: ...d-in Token-Übergabe:** „Add-in verbinden"-Panel in der Session-Seite – Token, Ses...
(DE_UNPAIRED_QUOTES)
🤖 Prompt for AI Agents