diff --git a/README.md b/README.md
index 7d5b273..c86353b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Algoria
-
+
Plataforma em português para estudar **algoritmos e decisões em código** através de leitura guiada: catálogo de problemas com várias soluções (brute-force, óptima, alternativa), **code player** linha-a-linha com três níveis de explicação, mini-guias em **Conceitos**, **curso modular** com avaliações locais, hub de **inglês técnico para entrevistas** (conteúdo em inglês) e guias de **engenharia aplicada** (front, back, DevOps).
@@ -11,7 +11,7 @@ Plataforma em português para estudar **algoritmos e decisões em código** atra
## Funcionalidades
- 📚 **Catálogo de problemas** — enunciados em Markdown, tags, dificuldade e várias implementações lado a lado quando existirem
-- 🎯 **Code player** — navegação linha-a-linha, destaque sintaxe (Shiki), painel com níveis Resumo / Detalhado / Deep dive e atalhos de teclado
+- 🎯 **Code player** — navegação linha-a-linha, destaque sintaxe (Shiki), painel com níveis Resumo / Detalhado / Deep dive e atalhos de teclado, visualização de estruturas de dados e execução de código linha a linha.
- 🧠 **Conceitos** — páginas longas (fundamentos, estruturas, padrões) carregadas do repositório em Markdown
- 🎓 **Curso guiado** — trilha modular com exemplos, MCQs e certificado por capítulo (progresso no browser)
- 🌍 **Interview English** — hub `/interview-en` com vocabulário e scripts 100% em inglês para entrevistas
diff --git a/app/admin/content/_components/dashboard/dashboard-filters.tsx b/app/admin/content/_components/dashboard/dashboard-filters.tsx
index 02c811e..3a47d3c 100644
--- a/app/admin/content/_components/dashboard/dashboard-filters.tsx
+++ b/app/admin/content/_components/dashboard/dashboard-filters.tsx
@@ -1,6 +1,6 @@
'use client';
-import { EDITORIAL_TYPES, SYSTEM_TYPE_OPTIONS, STATUS_FILTERS } from "./dashboard-types";
+import { EDITORIAL_TYPES, SYSTEM_TYPE_OPTIONS, STATUS_FILTERS, CATEGORY_OPTIONS } from "./dashboard-types";
interface DashboardFiltersProps {
tab: 'editorial' | 'sistema';
@@ -10,6 +10,8 @@ interface DashboardFiltersProps {
onTypeFilterChange: (v: string) => void;
statusFilter: string;
onStatusFilterChange: (v: string) => void;
+ categoryFilter: string;
+ onCategoryFilterChange: (v: string) => void;
onClearFilters: () => void;
}
@@ -21,6 +23,8 @@ export function DashboardFilters({
onTypeFilterChange,
statusFilter,
onStatusFilterChange,
+ categoryFilter,
+ onCategoryFilterChange,
onClearFilters,
}: DashboardFiltersProps) {
const typeOptions = tab === 'editorial' ? EDITORIAL_TYPES : SYSTEM_TYPE_OPTIONS;
@@ -63,6 +67,20 @@ export function DashboardFilters({
))}
+ {typeOptions === EDITORIAL_TYPES && (
+
+ )}
+
- {(search || typeFilter || statusFilter) && (
+ {(search || typeFilter || statusFilter || categoryFilter) && (
+
+
+
Implementações (Multi-Language)
diff --git a/app/admin/content/_components/types.ts b/app/admin/content/_components/types.ts
index 0e0f452..e1fa933 100644
--- a/app/admin/content/_components/types.ts
+++ b/app/admin/content/_components/types.ts
@@ -29,15 +29,20 @@ export interface EditorSolution {
name: string;
kind: string;
language: string;
+ entryFunction?: string;
+ simulatorCode?: string;
complexity: {
time: string;
space: string;
rationale: string;
};
- entryFunction?: string;
};
codeByLanguage: Record;
introMd: string;
+ annotations: unknown[];
+ executionTrace?: {
+ steps: unknown[];
+ };
}
export type ContentStatus =
@@ -121,6 +126,7 @@ export const DEFAULT_META: Record> = {
problem: {
difficulty: "easy",
categories: [],
+ hasBespokeVisualizer: false,
estimatedMinutes: 15,
access: "pro",
recommendedOrder: 1,
diff --git a/app/admin/content/content-dashboard-client.tsx b/app/admin/content/content-dashboard-client.tsx
index b9a9450..9852bee 100644
--- a/app/admin/content/content-dashboard-client.tsx
+++ b/app/admin/content/content-dashboard-client.tsx
@@ -25,6 +25,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
const [search, setSearch] = useState(searchParams.get('search') ?? '');
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') ?? '');
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') ?? '');
+ const [categoryFilter, setCategoryFilter] = useState(searchParams.get('category') ?? '');
const [accessFilter, setAccessFilter] = useState(searchParams.get('access') ?? '');
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
@@ -46,6 +47,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
const urlType = searchParams.get('type') ?? '';
const urlTab = searchParams.get('tab') ?? '';
const urlStatus = searchParams.get('status') ?? '';
+ const urlCategory = searchParams.get('category') ?? '';
const urlSearch = searchParams.get('search') ?? '';
const urlAccess = searchParams.get('access') ?? '';
const urlPage = parseInt(searchParams.get('page') || '1');
@@ -65,6 +67,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
setTypeFilter(urlType);
setStatusFilter(urlStatus);
+ setCategoryFilter(urlCategory);
setSearch(urlSearch);
setAccessFilter(urlAccess);
setPage(urlPage);
@@ -77,6 +80,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
search: s,
type: typeFilter || undefined,
status: statusFilter || undefined,
+ category: categoryFilter || undefined,
tab,
access: accessFilter || undefined,
});
@@ -85,20 +89,21 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
setRows(result.contents as unknown as ContentRow[]);
setTotal(result.total);
}
- }, [typeFilter, statusFilter, tab, accessFilter]);
+ }, [typeFilter, statusFilter, categoryFilter, tab, accessFilter]);
useEffect(() => {
const timer = setTimeout(() => {
load(page, search);
}, 300);
return () => clearTimeout(timer);
- }, [page, typeFilter, statusFilter, search, tab, accessFilter, load]);
+ }, [page, typeFilter, statusFilter, categoryFilter, search, tab, accessFilter, load]);
function handleTabChange(newTab: 'editorial' | 'sistema') {
setTab(newTab);
setPage(1);
setTypeFilter('');
setStatusFilter('');
+ setCategoryFilter('');
setSearch('');
// Update URL
@@ -106,6 +111,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
params.set('tab', newTab);
params.delete('type');
params.delete('status');
+ params.delete('category');
params.delete('search');
params.set('page', '1');
window.history.pushState(null, '', `?${params.toString()}`);
@@ -141,6 +147,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
setSearch('');
setTypeFilter('');
setStatusFilter('');
+ setCategoryFilter('');
setPage(1);
}
@@ -182,7 +189,7 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
)}
{
setSearch(v);
@@ -201,9 +208,15 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean }
setPage(1);
updateUrl({ status: v, page: '1' });
}}
+ categoryFilter={categoryFilter}
+ onCategoryFilterChange={(v) => {
+ setCategoryFilter(v);
+ setPage(1);
+ updateUrl({ category: v, page: '1' });
+ }}
onClearFilters={() => {
clearFilters();
- updateUrl({ type: null, status: null, search: null, page: '1' });
+ updateUrl({ type: null, status: null, category: null, search: null, page: '1' });
}}
/>
diff --git a/app/admin/pricing/pricing-manager-client.tsx b/app/admin/pricing/pricing-manager-client.tsx
index a31fcb3..081f04b 100644
--- a/app/admin/pricing/pricing-manager-client.tsx
+++ b/app/admin/pricing/pricing-manager-client.tsx
@@ -1,10 +1,24 @@
"use client";
-import { useState, useTransition, useEffect } from "react";
-import { updatePricingPlan, updateInventoryCategory, syncAllProContent, addPricingFeature, getPricingFeatures, removePricingFeature, updatePricingFeature, getPricingSummary, updateContentAccess } from "@/lib/actions/admin";
+import {
+ addPricingFeature,
+ getPricingFeatures,
+ getPricingSummary,
+ removePricingFeature,
+ syncAllProContent,
+ updateContentAccess,
+ updateInventoryCategory,
+ updatePricingFeature,
+ updatePricingPlan,
+} from "@/lib/actions/admin";
+import { useEffect, useState, useTransition } from "react";
+import {
+ CATEGORY_OPTIONS,
+ EDITORIAL_TYPES,
+} from "../content/_components/dashboard/dashboard-types";
import { InventorySummary } from "./_components/inventory-summary";
-import { PlanConfigCard } from "./_components/plan-config-card";
import { InventoryTable } from "./_components/inventory-table";
+import { PlanConfigCard } from "./_components/plan-config-card";
interface Plan {
id: string;
@@ -21,6 +35,7 @@ interface InventoryItem {
contentTitle: string;
contentType: string;
contentSlug: string;
+ metadata?: unknown;
}
interface Feature {
@@ -39,26 +54,29 @@ interface PricingManagerClientProps {
initialInventory: InventoryItem[];
}
-export function PricingManagerClient({
- initialPlans,
- initialInventory
+export function PricingManagerClient({
+ initialPlans,
+ initialInventory,
}: PricingManagerClientProps) {
const [activeTab, setActiveTab] = useState<"plans" | "inventory">("plans");
const [plans, setPlans] = useState(initialPlans);
const [inventory, setInventory] = useState(initialInventory);
const [isPending, startTransition] = useTransition();
-
+
const [freeFeatures, setFreeFeatures] = useState([]);
const [proFeatures, setProFeatures] = useState([]);
const [summary, setSummary] = useState({ pro: [], free: [] });
+ const [categoryFilter, setCategoryFilter] = useState("");
+ const [typeFilter, setTypeFilter] = useState("");
+
// Load data on mount
useEffect(() => {
async function loadData() {
const [free, pro, summ] = await Promise.all([
getPricingFeatures("free"),
getPricingFeatures("pro"),
- getPricingSummary()
+ getPricingSummary(),
]);
setFreeFeatures(free as Feature[]);
setProFeatures(pro as Feature[]);
@@ -67,11 +85,28 @@ export function PricingManagerClient({
loadData();
}, []);
+ // Filtered inventory based on algorithm category and content type
+ const filteredInventory = inventory.filter((item) => {
+ // Type filter
+ if (typeFilter && item.contentType !== typeFilter) return false;
+
+ // Category filter
+ if (categoryFilter) {
+ const meta = item.metadata as { categories?: string[] } | undefined;
+ const categories = meta?.categories;
+ if (!Array.isArray(categories) || !categories.includes(categoryFilter)) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+
async function handleAddFeature(planId: string, label: string) {
startTransition(async () => {
- await addPricingFeature(planId, 'manual', label);
+ await addPricingFeature(planId, "manual", label);
const updated = await getPricingFeatures(planId);
- if (planId === 'free') setFreeFeatures(updated as Feature[]);
+ if (planId === "free") setFreeFeatures(updated as Feature[]);
else setProFeatures(updated as Feature[]);
});
}
@@ -80,17 +115,25 @@ export function PricingManagerClient({
startTransition(async () => {
await removePricingFeature(id);
const updated = await getPricingFeatures(planId);
- if (planId === 'free') setFreeFeatures(updated as Feature[]);
+ if (planId === "free") setFreeFeatures(updated as Feature[]);
else setProFeatures(updated as Feature[]);
});
}
- async function handleUpdateFeature(id: string, label: string, planId: string) {
+ async function handleUpdateFeature(
+ id: string,
+ label: string,
+ planId: string,
+ ) {
// Update local state first for instant feedback
- if (planId === 'free') {
- setFreeFeatures(freeFeatures.map(f => f.id === id ? { ...f, label } : f));
+ if (planId === "free") {
+ setFreeFeatures(
+ freeFeatures.map((f) => (f.id === id ? { ...f, label } : f)),
+ );
} else {
- setProFeatures(proFeatures.map(f => f.id === id ? { ...f, label } : f));
+ setProFeatures(
+ proFeatures.map((f) => (f.id === id ? { ...f, label } : f)),
+ );
}
startTransition(async () => {
@@ -98,10 +141,16 @@ export function PricingManagerClient({
});
}
- async function handleUpdatePlan(id: string, field: keyof Plan, value: string) {
- const updatedPlans = plans.map(p => p.id === id ? { ...p, [field]: value } : p);
+ async function handleUpdatePlan(
+ id: string,
+ field: keyof Plan,
+ value: string,
+ ) {
+ const updatedPlans = plans.map((p) =>
+ p.id === id ? { ...p, [field]: value } : p,
+ );
setPlans(updatedPlans);
-
+
startTransition(async () => {
await updatePricingPlan(id, { [field]: value });
});
@@ -110,7 +159,11 @@ export function PricingManagerClient({
async function handleUpdateCategory(id: string, category: string) {
startTransition(async () => {
await updateInventoryCategory(id, category);
- setInventory(inventory.map(item => item.id === id ? { ...item, pricingCategory: category } : item));
+ setInventory(
+ inventory.map((item) =>
+ item.id === id ? { ...item, pricingCategory: category } : item,
+ ),
+ );
// Refresh summary
const summ = await getPricingSummary();
setSummary(summ as PricingSummary);
@@ -119,9 +172,9 @@ export function PricingManagerClient({
async function handleMakeFree(contentId: string) {
startTransition(async () => {
- await updateContentAccess(contentId, 'free');
+ await updateContentAccess(contentId, "free");
// Update local state: remove from inventory
- setInventory(inventory.filter(item => item.contentId !== contentId));
+ setInventory(inventory.filter((item) => item.contentId !== contentId));
// Refresh summary
const summ = await getPricingSummary();
setSummary(summ as PricingSummary);
@@ -169,10 +222,10 @@ export function PricingManagerClient({
) : (
-
+
+
+
+
+
+
+
+
+ {(categoryFilter || typeFilter) && (
+
+ Mostrando {filteredInventory.length} de {inventory.length}{" "}
+ itens
+
+ )}
+
+
+
+
+
)}
);
diff --git a/app/problems/[slug]/[solution]/page.tsx b/app/problems/[slug]/[solution]/page.tsx
index 846b18d..e01006e 100644
--- a/app/problems/[slug]/[solution]/page.tsx
+++ b/app/problems/[slug]/[solution]/page.tsx
@@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
-import { CodePlayer } from '@/components/code-player/code-player';
+import { DynamicPlayerWrapper } from '@/components/code-player/dynamic-player-wrapper';
import { ComplexityBadge } from '@/components/complexity/complexity-badge';
import { DifficultyBadge } from '@/components/catalog/difficulty-badge';
import { JsonLdScript } from '@/components/seo/json-ld';
@@ -201,14 +201,15 @@ export default async function SolutionPage({
/>
) : null}
-
diff --git a/app/tests/[track]/_components/tests-filters.tsx b/app/tests/[track]/_components/tests-filters.tsx
index 09a508d..c1d57b1 100644
--- a/app/tests/[track]/_components/tests-filters.tsx
+++ b/app/tests/[track]/_components/tests-filters.tsx
@@ -26,20 +26,39 @@ export function TestsFilters({
const currentLevel = searchParams.get("level");
const currentTopic = searchParams.get("topic");
const currentDifficulty = searchParams.get("difficulty");
- const [searchTerm, setSearchTerm] = useState(searchParams.get("q") || "");
+ const currentQ = searchParams.get("q") || "";
+ const [searchTerm, setSearchTerm] = useState(currentQ);
+ const [prevQ, setPrevQ] = useState(currentQ);
+
+ // Sync state with URL when it changes externally (e.g. back button)
+ // We do this during render to avoid cascading effects and lint errors
+ if (currentQ !== prevQ) {
+ setPrevQ(currentQ);
+ setSearchTerm(currentQ);
+ }
const updateFilters = useCallback(
(updates: Record) => {
const params = new URLSearchParams(searchParams.toString());
+ let hasChanges = false;
Object.entries(updates).forEach(([key, value]) => {
- if (value === undefined || value === "all") {
- params.delete(key);
- } else {
- params.set(key, value);
+ const currentValue = params.get(key);
+ const newValue = value === "all" ? undefined : value;
+
+ if (newValue === undefined) {
+ if (params.has(key)) {
+ params.delete(key);
+ hasChanges = true;
+ }
+ } else if (currentValue !== newValue) {
+ params.set(key, newValue);
+ hasChanges = true;
}
});
+ if (!hasChanges) return;
+
startTransition(() => {
router.push(`/tests/${track}?${params.toString()}`);
});
@@ -48,16 +67,22 @@ export function TestsFilters({
);
useEffect(() => {
+ // Only trigger if searchTerm is different from what's in the URL
+ const urlSearchTerm = searchParams.get("q") || "";
+ if (searchTerm === urlSearchTerm) return;
+
const delayDebounceFn = setTimeout(() => {
updateFilters({ q: searchTerm || undefined });
}, 400);
return () => clearTimeout(delayDebounceFn);
- }, [searchTerm, updateFilters]);
+ }, [searchTerm, updateFilters, searchParams]);
const clearFilters = () => {
setSearchTerm("");
- router.push(`/tests/${track}`);
+ if (searchParams.toString() !== "") {
+ router.push(`/tests/${track}`);
+ }
};
const hasActiveFilters =
diff --git a/components/code-player/code-player.tsx b/components/code-player/code-player.tsx
index 9bda05e..1ddfbff 100644
--- a/components/code-player/code-player.tsx
+++ b/components/code-player/code-player.tsx
@@ -1,20 +1,24 @@
-'use client';
+"use client";
-import { useEffect, useMemo } from 'react';
+import { useEffect, useMemo } from "react";
-import type { ExecutionTraceStep, LineAnnotation } from '@/lib/content/schemas';
-import type { HighlightedLine } from '@/lib/content/shiki';
+import type { ExecutionTraceStep, LineAnnotation } from "@/lib/content/schemas";
+import type { HighlightedLine } from "@/lib/content/shiki";
-import { renderMarkdown } from '@/lib/content/markdown';
-import { getSolutionResumeLine, touchSolutionLastLine } from '@/lib/progress/local-progress';
+import { renderMarkdown } from "@/lib/content/markdown";
+import {
+ getSolutionResumeLine,
+ touchSolutionLastLine,
+} from "@/lib/progress/local-progress";
-import { CodeView } from './code-view';
-import { ExecutionTracePanel } from './execution-trace-panel';
-import { ExplanationPanel } from './explanation-panel';
-import { KeyboardShortcuts } from './keyboard-shortcuts';
-import { PlayerAnalyticsSync } from './player-analytics-sync';
-import { PlayerControls } from './player-controls';
-import { usePlayerStore } from './use-player-store';
+import { CodeView } from "./code-view";
+import { ExecutionTracePanel } from "./execution-trace-panel";
+import { ExplanationPanel } from "./explanation-panel";
+import { KeyboardShortcuts } from "./keyboard-shortcuts";
+import { PlayerAnalyticsSync } from "./player-analytics-sync";
+import { PlayerControls } from "./player-controls";
+import { usePlayerStore } from "./use-player-store";
+import { BESPOKE_VISUALIZERS, hasBespokeVisualizer } from "./visualizers";
interface Props {
lines: HighlightedLine[];
@@ -25,6 +29,7 @@ interface Props {
executionTrace?: ExecutionTraceStep[];
problemSlug?: string;
solutionSlug?: string;
+ autoPlay?: boolean;
}
/**
@@ -45,11 +50,15 @@ export function CodePlayer({
executionTrace,
problemSlug,
solutionSlug,
+ autoPlay,
}: Props) {
const initialize = usePlayerStore((s) => s.initialize);
const tutorialMode = annotations.length > 0 && !readOnlyExplanationMd;
- const annotatedLineSet = useMemo(() => new Set(annotations.map((a) => a.line)), [annotations]);
+ const annotatedLineSet = useMemo(
+ () => new Set(annotations.map((a) => a.line)),
+ [annotations],
+ );
const annotatedLines = useMemo(
() => annotations.map((a) => a.line).sort((a, b) => a - b),
[annotations],
@@ -58,12 +67,22 @@ export function CodePlayer({
useEffect(() => {
if (tutorialMode) {
const resume =
- problemSlug && solutionSlug ? getSolutionResumeLine(problemSlug, solutionSlug, annotatedLines) : undefined;
- initialize(annotatedLines, resume);
+ problemSlug && solutionSlug
+ ? getSolutionResumeLine(problemSlug, solutionSlug, annotatedLines)
+ : undefined;
+ initialize(annotatedLines, resume, executionTrace, autoPlay);
} else {
- initialize([], 1);
+ initialize([], 1, executionTrace, autoPlay);
}
- }, [annotatedLines, initialize, tutorialMode, problemSlug, solutionSlug]);
+ }, [
+ annotatedLines,
+ initialize,
+ tutorialMode,
+ problemSlug,
+ solutionSlug,
+ executionTrace,
+ autoPlay,
+ ]);
useEffect(() => {
if (!tutorialMode || !problemSlug || !solutionSlug) return;
@@ -74,7 +93,10 @@ export function CodePlayer({
if (line === lastLine) return;
lastLine = line;
if (timer) clearTimeout(timer);
- timer = setTimeout(() => touchSolutionLastLine(problemSlug, solutionSlug, line), 400);
+ timer = setTimeout(
+ () => touchSolutionLastLine(problemSlug, solutionSlug, line),
+ 400,
+ );
});
return () => {
if (timer) clearTimeout(timer);
@@ -97,10 +119,13 @@ export function CodePlayer({
/>
{tutorialMode ? : null}
{tutorialMode ? (
-
- Atalhos: ←/→ mudar de linha,{' '}
- espaço play/pause, 1/2/
- 3 mudar nível.
+
+ Atalhos: ←/
+ → Navegar,{" "}
+ Espaço Play/Pause,{" "}
+ 1/
+ 2/
+ 3 Nível.
) : null}
@@ -113,14 +138,28 @@ export function CodePlayer({
className="prose prose-zinc dark:prose-invert prose-sm max-w-none
prose-code:text-blue-600 dark:prose-code:text-blue-400
prose-code:before:content-none prose-code:after:content-none"
- dangerouslySetInnerHTML={{ __html: renderMarkdown(readOnlyExplanationMd) }}
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(readOnlyExplanationMd),
+ }}
/>
) : (
-
+
)}
- {showTrace ? : null}
+ {showTrace ? (
+ problemSlug && hasBespokeVisualizer(problemSlug) ? (
+ (() => {
+ const Visualizer = BESPOKE_VISUALIZERS[problemSlug];
+ return ;
+ })()
+ ) : (
+
+ )
+ ) : null}
);
}
diff --git a/components/code-player/code-view.tsx b/components/code-player/code-view.tsx
index 33bd1e2..ad0210b 100644
--- a/components/code-player/code-view.tsx
+++ b/components/code-player/code-view.tsx
@@ -31,19 +31,13 @@ export function CodeView({ lines, annotatedLineSet, interactiveSteps = true }: P
const containerRef = useRef(null);
useEffect(() => {
- if (!interactiveSteps) return;
- const container = containerRef.current;
- if (!container) return;
- const target = container.querySelector(`[data-line="${currentLine}"]`);
- if (target) {
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
+ // Scroll automático desativado a pedido do utilizador para evitar saltos de tela.
}, [currentLine, interactiveSteps]);
return (
diff --git a/components/code-player/dynamic-input-form.tsx b/components/code-player/dynamic-input-form.tsx
new file mode 100644
index 0000000..2864575
--- /dev/null
+++ b/components/code-player/dynamic-input-form.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Play, RotateCcw } from "lucide-react";
+import { useState } from "react";
+
+interface Props {
+ onRun: (...args: unknown[]) => void;
+ onReset: () => void;
+ problemSlug: string;
+}
+
+export function DynamicInputForm({
+ onRun,
+ onReset,
+ problemSlug,
+}: Props) {
+ // Configurações por slug
+ const configs: Record
unknown[];
+ }> = {
+ "two-sum": {
+ labels: ["Dados de Entrada (Números)", "Alvo (Target)"],
+ placeholders: ["Ex: 2, 7, 11, 15", "9"],
+ defaults: ["2, 7, 11, 15", "9"],
+ types: ["text", "number"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean).map(Number),
+ Number(vals[1])
+ ]
+ },
+ "minimum-window-substring": {
+ labels: ["String S (Texto Principal)", "String T (Caracteres Necessários)"],
+ placeholders: ["Ex: ADOBECODEBANC", "ABC"],
+ defaults: ["ADOBECODEBANC", "ABC"],
+ types: ["text", "text"],
+ parser: (vals) => [vals[0].trim(), vals[1].trim()]
+ },
+ "longest-substring-without-repeating": {
+ labels: ["String de Entrada (S)"],
+ placeholders: ["Ex: abcabcbb"],
+ defaults: ["abcabcbb"],
+ types: ["text"],
+ parser: (vals) => [vals[0].trim()]
+ },
+ "subarray-sum-equals-k": {
+ labels: ["Números (nums)", "Alvo (k)"],
+ placeholders: ["Ex: 1, 1, 1", "2"],
+ defaults: ["1, 1, 1", "2"],
+ types: ["text", "number"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean).map(Number),
+ Number(vals[1])
+ ]
+ },
+ "trapping-rain-water": {
+ labels: ["Alturas (height)"],
+ placeholders: ["Ex: 0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1"],
+ defaults: ["0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1"],
+ types: ["text"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean).map(Number)
+ ]
+ },
+ "group-anagrams": {
+ labels: ["Lista de Palavras (strs)"],
+ placeholders: ["Ex: eat, tea, tan, ate, nat, bat"],
+ defaults: ["eat, tea, tan, ate, nat, bat"],
+ types: ["text"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean)
+ ]
+ },
+ "daily-temperatures": {
+ labels: ["Temperaturas (Celsius)"],
+ placeholders: ["Ex: 73, 74, 75, 71, 69, 72, 76, 73"],
+ defaults: ["73, 74, 75, 71, 69, 72, 76, 73"],
+ types: ["text"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean).map(Number)
+ ]
+ },
+ "3sum": {
+ labels: ["Números (nums)"],
+ placeholders: ["Ex: -1, 0, 1, 2, -1, -4"],
+ defaults: ["-1, 0, 1, 2, -1, -4"],
+ types: ["text"],
+ parser: (vals) => [
+ vals[0].split(",").map(s => s.trim()).filter(Boolean).map(Number)
+ ]
+ }
+ };
+
+ const config = configs[problemSlug] || configs["two-sum"];
+ const [inputs, setInputs] = useState(config.defaults);
+
+ const handleRun = () => {
+ const parsed = config.parser(inputs);
+ onRun(...parsed);
+ };
+
+ const updateInput = (idx: number, val: string) => {
+ const next = [...inputs];
+ next[idx] = val;
+ setInputs(next);
+ };
+
+ return (
+
+
+
+ {config.labels.map((label, i) => (
+
+
+ updateInput(i, e.target.value)}
+ placeholder={config.placeholders[i]}
+ type={config.types[i]}
+ className="bg-zinc-50 dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 rounded-none h-12 text-base font-mono focus-visible:ring-zinc-900"
+ />
+
+ ))}
+
+
+
+
+
+
+
+ INFO: Configura o cenário de teste acima e dispara o motor de execução.
+
+
+
+ );
+}
diff --git a/components/code-player/dynamic-player-wrapper.tsx b/components/code-player/dynamic-player-wrapper.tsx
new file mode 100644
index 0000000..59010df
--- /dev/null
+++ b/components/code-player/dynamic-player-wrapper.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { ExecutionTraceStep, LineAnnotation } from "@/lib/content/schemas";
+import { HighlightedLine } from "@/lib/content/shiki";
+import { getSimulator } from "@/lib/simulators";
+import { useCallback, useState } from "react";
+import { CodePlayer } from "./code-player";
+import { DynamicInputForm } from "./dynamic-input-form";
+import { hasBespokeVisualizer } from "./visualizers";
+
+interface Props {
+ lines: HighlightedLine[];
+ annotations: LineAnnotation[];
+ conceptTitles: Record;
+ readOnlyExplanationMd?: string;
+ executionTrace: ExecutionTraceStep[];
+ problemSlug: string;
+ solutionSlug: string;
+ autoPlay?: boolean;
+ simulatorCode?: string;
+}
+
+export function DynamicPlayerWrapper(props: Props) {
+ const [dynamicTrace, setDynamicTrace] = useState<
+ ExecutionTraceStep[] | undefined
+ >(undefined);
+ const [shouldAutoPlay, setShouldAutoPlay] = useState(false);
+
+ const isBespoke = hasBespokeVisualizer(props.problemSlug);
+
+ const handleRun = useCallback(
+ (...args: unknown[]) => {
+ // 1. Prioridade para o código vindo do banco (Admin)
+ if (props.simulatorCode) {
+ try {
+ // Criamos uma função que aceita argumentos variáveis
+ const dynamicSim = new Function(
+ "...args",
+ `const simulate = ${props.simulatorCode};
+ return typeof simulate === 'function' ? simulate(...args) : null;`
+ );
+
+ const trace = dynamicSim(...args);
+
+ if (trace && Array.isArray(trace) && trace.length > 0) {
+ setDynamicTrace(trace);
+ setShouldAutoPlay(true);
+ return;
+ }
+ } catch (err) {
+ console.error("Erro ao executar simulador dinâmico:", err);
+ }
+ }
+
+ // 2. Fallback para simuladores locais (legado)
+ const simulator = getSimulator(props.problemSlug, props.solutionSlug);
+ if (simulator) {
+ const trace = simulator(...args);
+ if (trace.length > 0) {
+ setDynamicTrace(trace);
+ setShouldAutoPlay(true);
+ }
+ }
+ },
+ [props.problemSlug, props.solutionSlug, props.simulatorCode],
+ );
+
+ const handleReset = useCallback(() => {
+ setDynamicTrace(undefined);
+ setShouldAutoPlay(false);
+ }, []);
+
+ return (
+
+ {isBespoke && (
+
+ )}
+
+
+
+ );
+}
diff --git a/components/code-player/execution-trace-panel.tsx b/components/code-player/execution-trace-panel.tsx
index 61a5892..8391296 100644
--- a/components/code-player/execution-trace-panel.tsx
+++ b/components/code-player/execution-trace-panel.tsx
@@ -13,11 +13,14 @@ interface Props {
export function ExecutionTracePanel({ steps }: Props) {
const currentLine = usePlayerStore((s) => s.currentLine);
+ const currentStepIndex = usePlayerStore((s) => s.currentStepIndex);
- const snapshot = useMemo(
- () => resolveExecutionSnapshot(currentLine, steps),
- [currentLine, steps],
- );
+ const snapshot = useMemo(() => {
+ if (currentStepIndex !== -1 && steps[currentStepIndex]) {
+ return steps[currentStepIndex].snapshot;
+ }
+ return resolveExecutionSnapshot(currentLine, steps);
+ }, [currentLine, currentStepIndex, steps]);
if (!snapshot) {
return (
@@ -25,7 +28,7 @@ export function ExecutionTracePanel({ steps }: Props) {
className="rounded-xl border border-dashed border-zinc-300 bg-zinc-50/80 p-4 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950/40 dark:text-zinc-400"
aria-label="Estado da execução (demonstração)"
>
- Sem modelo visual para esta linha — avança no player para ver arrays e mapas quando definidos no{' '}
+ Sem modelo visual para esta linha — avance no player para ver arrays e mapas quando definidos no{' '}
trace.json.
);
diff --git a/components/code-player/explanation-panel.tsx b/components/code-player/explanation-panel.tsx
index efc83e9..847bf06 100644
--- a/components/code-player/explanation-panel.tsx
+++ b/components/code-player/explanation-panel.tsx
@@ -37,8 +37,8 @@ export function ExplanationPanel({ annotations, conceptTitles }: Props) {
if (!annotation) {
return (
-