feat(dm): add dashboard with oauth, dice, initiative, open5e, notes

This commit is contained in:
emil
2026-05-14 23:48:36 +03:00
parent 7efe188a9a
commit 1a092e05c1
144 changed files with 8643 additions and 35 deletions
+50
View File
@@ -0,0 +1,50 @@
---
import DmButton from './DmButton.astro';
const user = Astro.locals.user;
---
{!user ? (
<div class="flex flex-col sm:flex-row gap-3">
<DmButton
href="/api/auth/login/vk"
variant="primary"
size="md"
class="bg-[#4C75A3] hover:bg-[#3d5f85] active:bg-[#2f4a68] shadow-[#4C75A3]/25"
>
Войти через VK
</DmButton>
<DmButton
href="/api/auth/login/yandex"
variant="primary"
size="md"
class="bg-[#FC3F1D] hover:bg-[#e03518] active:bg-[#c42d14] shadow-[#FC3F1D]/25"
>
Войти через Яндекс
</DmButton>
</div>
) : (
<div class="flex items-center gap-3 flex-wrap">
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
class="w-8 h-8 rounded-full object-cover border border-[var(--border-color)]"
width="32"
height="32"
/>
) : (
<div class="w-8 h-8 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] flex items-center justify-center text-sm font-semibold text-[var(--text-secondary)]">
{user.name.charAt(0).toUpperCase()}
</div>
)}
<span class="text-[var(--text-primary)] font-medium">{user.name}</span>
<DmButton
href="/api/auth/logout"
variant="ghost"
size="sm"
>
Выйти
</DmButton>
</div>
)}
+262
View File
@@ -0,0 +1,262 @@
---
import DmCard from "./DmCard.astro";
import DmInput from "./DmInput.astro";
import DmButton from "./DmButton.astro";
const quickDice = [
{ label: "d4", notation: "d4" },
{ label: "d6", notation: "d6" },
{ label: "d8", notation: "d8" },
{ label: "d10", notation: "d10" },
{ label: "d12", notation: "d12" },
{ label: "d20", notation: "d20" },
{ label: "d100", notation: "d100" },
];
---
<DmCard>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
Dice Roller
</h3>
<button
id="clear-history"
class="text-sm text-[var(--text-secondary)] hover:text-[var(--accent)] active:text-[var(--accent-dark)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] rounded-md px-2 py-1"
>
Clear History
</button>
</div>
<div
class="grid grid-cols-4 sm:grid-cols-7 gap-2"
id="quick-dice-grid"
role="group"
aria-label="Quick dice selectors"
>
{
quickDice.map((d) => (
<button
type="button"
data-notation={d.notation}
class="quick-dice-btn bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-lg px-2 py-2 text-sm font-semibold hover:border-[var(--accent)] hover:text-[var(--accent)] active:bg-[var(--accent)]/10 active:border-[var(--accent)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
>
{d.label}
</button>
))
}
</div>
<div class="flex gap-2 items-end">
<DmInput
id="dice-notation"
name="notation"
placeholder="e.g. 2d6+3"
label="Custom Roll"
class="flex-1"
/>
<DmButton id="roll-btn" variant="primary" size="md">
Roll
</DmButton>
</div>
<div
id="result-display"
class="hidden rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-4 text-center"
>
<div class="text-sm text-[var(--text-secondary)] mb-1" id="result-label">
Result
</div>
<div
class="text-4xl font-bold text-[var(--accent)]"
id="result-total"
>
0
</div>
<div
class="text-sm text-[var(--text-secondary)] mt-1"
id="result-details"
>
</div>
</div>
<div id="history-section" class="hidden">
<h4 class="text-sm font-medium text-[var(--text-secondary)] mb-2">
Recent Rolls
</h4>
<ul
id="history-list"
class="space-y-1 max-h-40 overflow-y-auto"
role="list"
aria-label="Roll history"
>
</ul>
</div>
</div>
</DmCard>
<script>
import { parseDiceNotation, rollDice } from "@/lib/client/dice";
import { popElement } from "@/lib/client/animations";
const STORAGE_KEY = "dice-roller-history";
const MAX_HISTORY = 10;
interface HistoryEntry {
notation: string;
total: number;
rolls: number[];
modifier: number;
timestamp: number;
}
function loadHistory(): HistoryEntry[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as HistoryEntry[]) : [];
} catch {
return [];
}
}
function saveHistory(history: HistoryEntry[]) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// sessionStorage may be unavailable
}
}
function addToHistory(entry: HistoryEntry) {
const history = loadHistory();
history.unshift(entry);
if (history.length > MAX_HISTORY) {
history.length = MAX_HISTORY;
}
saveHistory(history);
renderHistory();
}
function clearHistory() {
saveHistory([]);
renderHistory();
}
function renderHistory() {
const history = loadHistory();
const section = document.getElementById("history-section");
const list = document.getElementById("history-list");
if (!section || !list) return;
if (history.length === 0) {
section.classList.add("hidden");
list.innerHTML = "";
return;
}
section.classList.remove("hidden");
list.innerHTML = history
.map(
(entry) => `
<li class="flex items-center justify-between rounded-md bg-[var(--bg-secondary)] px-3 py-2 text-sm">
<span class="text-[var(--text-secondary)]">${entry.notation}</span>
<span class="font-semibold text-[var(--text-primary)]">${entry.total}</span>
</li>
`,
)
.join("");
}
function showResult(
notation: string,
result: { rolls: number[]; total: number; modifier: number },
) {
const display = document.getElementById("result-display");
const totalEl = document.getElementById("result-total");
const detailsEl = document.getElementById("result-details");
const labelEl = document.getElementById("result-label");
if (!display || !totalEl || !detailsEl || !labelEl) return;
display.classList.remove("hidden");
totalEl.textContent = String(result.total);
labelEl.textContent = notation;
const rolls = result.rolls.join(", ");
if (result.modifier !== 0) {
const sign = result.modifier > 0 ? "+" : "";
detailsEl.textContent = `Rolls: ${rolls} ${sign}${result.modifier}`;
} else {
detailsEl.textContent = `Rolls: ${rolls}`;
}
popElement(totalEl);
addToHistory({
notation,
total: result.total,
rolls: result.rolls,
modifier: result.modifier,
timestamp: Date.now(),
});
}
function performRoll(notation: string) {
const trimmed = notation.trim();
if (!trimmed) return;
try {
const parsed = parseDiceNotation(trimmed);
const result = rollDice(parsed.count, parsed.sides, parsed.modifier);
showResult(trimmed, result);
} catch {
const display = document.getElementById("result-display");
const totalEl = document.getElementById("result-total");
const detailsEl = document.getElementById("result-details");
const labelEl = document.getElementById("result-label");
if (!display || !totalEl || !detailsEl || !labelEl) return;
display.classList.remove("hidden");
totalEl.textContent = "?";
labelEl.textContent = trimmed;
detailsEl.textContent = "Invalid notation";
}
}
const quickButtons = document.querySelectorAll<HTMLButtonElement>(
".quick-dice-btn",
);
quickButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const notation = btn.dataset.notation;
if (notation) {
const input = document.getElementById(
"dice-notation",
) as HTMLInputElement | null;
if (input) input.value = notation;
performRoll(notation);
}
});
});
const rollBtn = document.getElementById("roll-btn");
rollBtn?.addEventListener("click", () => {
const input = document.getElementById(
"dice-notation",
) as HTMLInputElement | null;
performRoll(input?.value ?? "");
});
const notationInput = document.getElementById(
"dice-notation",
) as HTMLInputElement | null;
notationInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
performRoll(notationInput.value);
}
});
const clearBtn = document.getElementById("clear-history");
clearBtn?.addEventListener("click", clearHistory);
renderHistory();
</script>
+51
View File
@@ -0,0 +1,51 @@
---
export interface Props {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
type?: "button" | "submit" | "reset";
class?: string;
disabled?: boolean;
href?: string;
}
const {
variant = "primary",
size = "md",
type = "button",
class: className = "",
disabled = false,
href,
} = Astro.props;
const variantClasses: Record<string, string> = {
primary:
"bg-[var(--accent)] text-white hover:bg-[var(--accent-light)] active:bg-[var(--accent-dark)] shadow-lg shadow-[var(--accent)]/25",
secondary:
"bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--accent)] hover:text-[var(--accent)]",
ghost:
"bg-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]",
};
const sizeClasses: Record<string, string> = {
sm: "px-4 py-1.5 text-sm rounded-lg",
md: "px-6 py-2.5 text-base rounded-xl",
lg: "px-8 py-3 text-lg rounded-xl",
};
const baseClasses =
"inline-flex items-center justify-center font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-colors duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed";
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
---
{
href ? (
<a href={href} class={classes} role="button">
<slot />
</a>
) : (
<button type={type} class={classes} disabled={disabled}>
<slot />
</button>
)
}
+24
View File
@@ -0,0 +1,24 @@
---
export interface Props {
class?: string;
padding?: "none" | "sm" | "md" | "lg";
}
const { class: className = "", padding = "md" } = Astro.props;
const paddingClasses: Record<string, string> = {
none: "",
sm: "p-4",
md: "p-5",
lg: "p-6",
};
const baseClasses =
"rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg shadow-black/20";
const classes = `${baseClasses} ${paddingClasses[padding]} ${className}`;
---
<div class={classes}>
<slot />
</div>
+44
View File
@@ -0,0 +1,44 @@
---
export interface Props {
title?: string;
class?: string;
}
const { title = "DM Dashboard", class: className = "" } = Astro.props;
---
<header
class={`w-full border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-sm ${className}`}
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-3">
<div
class="inline-flex w-9 h-9 rounded-lg bg-[var(--accent)]/10 items-center justify-center text-[var(--accent)] shrink-0"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
</div>
<span class="text-lg font-bold text-[var(--text-primary)] tracking-tight">
{title}
</span>
</div>
<div class="flex items-center gap-2">
<slot />
</div>
</div>
</div>
</header>
+60
View File
@@ -0,0 +1,60 @@
---
export interface Props {
id?: string;
name?: string;
type?: string;
value?: string | number;
placeholder?: string;
label?: string;
required?: boolean;
class?: string;
min?: number | string;
max?: number | string;
step?: number | string;
}
const {
id,
name,
type = "text",
value,
placeholder,
label,
required = false,
class: className = "",
min,
max,
step,
} = Astro.props;
const inputClasses =
"w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-colors";
const classes = `${inputClasses} ${className}`;
---
<div class="w-full">
{
label && (
<label
for={id}
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
{label}
{required && <span class="text-[var(--accent)]">*</span>}
</label>
)
}
<input
id={id}
name={name}
type={type}
value={value}
placeholder={placeholder}
required={required}
min={min}
max={max}
step={step}
class={classes}
/>
</div>
+297
View File
@@ -0,0 +1,297 @@
---
import DmButton from "./DmButton.astro";
import DmInput from "./DmInput.astro";
---
<div id="initiative-tracker" class="w-full max-w-xl mx-auto">
<!-- Add combatant form -->
<div class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-5 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 items-end">
<div class="sm:col-span-5">
<DmInput
id="it-name"
name="name"
type="text"
placeholder="Combatant name"
label="Name"
/>
</div>
<div class="sm:col-span-3">
<DmInput
id="it-modifier"
name="modifier"
type="number"
value={0}
placeholder="Modifier"
label="Modifier"
/>
</div>
<div class="sm:col-span-2">
<DmButton id="it-roll-btn" variant="secondary" size="sm" class="w-full">
Roll d20
</DmButton>
</div>
<div class="sm:col-span-2">
<DmButton id="it-add-btn" variant="primary" size="sm" class="w-full">
Add
</DmButton>
</div>
</div>
<div class="mt-3 flex items-center gap-3">
<DmInput
id="it-initiative"
name="initiative"
type="number"
placeholder="Or enter total"
label="Initiative"
class="sm:max-w-[120px]"
/>
<span class="text-xs text-[var(--text-secondary)]">
Roll d20 + modifier, or enter total manually
</span>
</div>
</div>
<!-- Combatant list -->
<div
id="it-list-panel"
class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-5 hidden"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-[var(--text-primary)] uppercase tracking-wider">
Turn Order
</h3>
<DmButton id="it-clear-btn" variant="ghost" size="sm">
Clear All
</DmButton>
</div>
<ul id="it-list" class="space-y-2" role="list" aria-label="Combatants"></ul>
<!-- Next turn button -->
<div class="mt-5 flex justify-center">
<DmButton id="it-next-btn" variant="primary" size="md" class="w-full sm:w-auto">
Next Turn
</DmButton>
</div>
</div>
<!-- Empty state -->
<div
id="it-empty"
class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-8 text-center"
>
<p class="text-[var(--text-secondary)] text-sm">
No combatants yet. Add a name and roll initiative to begin.
</p>
</div>
</div>
<script>
import {
sortByInitiative,
getNextActiveIndex,
rollInitiative,
} from "@/lib/client/initiative";
const STORAGE_KEY = "it-combatants";
// DOM refs
const nameInput = document.getElementById("it-name") as HTMLInputElement;
const modifierInput = document.getElementById(
"it-modifier",
) as HTMLInputElement;
const initiativeInput = document.getElementById(
"it-initiative",
) as HTMLInputElement;
const rollBtn = document.getElementById("it-roll-btn");
const addBtn = document.getElementById("it-add-btn");
const nextBtn = document.getElementById("it-next-btn");
const clearBtn = document.getElementById("it-clear-btn");
const listEl = document.getElementById("it-list");
const listPanel = document.getElementById("it-list-panel");
const emptyState = document.getElementById("it-empty");
let activeIndex = 0;
interface CombatantData {
id: string;
name: string;
initiative: number;
}
function loadCombatants(): CombatantData[] {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "[]");
} catch {
return [];
}
}
function saveCombatants(combatants: CombatantData[]) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(combatants));
}
function generateId(): string {
return Math.random().toString(36).slice(2, 9);
}
function updateVisibility() {
const combatants = loadCombatants();
const hasCombatants = combatants.length > 0;
listPanel?.classList.toggle("hidden", !hasCombatants);
emptyState?.classList.toggle("hidden", hasCombatants);
}
function renderList() {
const combatants = sortByInitiative(loadCombatants());
if (!listEl) return;
listEl.innerHTML = "";
combatants.forEach((c, index) => {
const isActive = index === activeIndex;
const li = document.createElement("li");
li.className = `flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors ${
isActive
? "border-[var(--accent)] bg-[var(--accent)]/10"
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
}`;
li.setAttribute("data-id", c.id);
li.innerHTML = `
<div class="flex items-center gap-3 min-w-0">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
isActive
? "bg-[var(--accent)] text-white"
: "bg-[var(--border-color)] text-[var(--text-secondary)]"
}">
${index + 1}
</span>
<span class="font-medium text-[var(--text-primary)] truncate">
${escapeHtml(c.name)}
</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-sm font-bold text-[var(--accent)] tabular-nums">
${c.initiative}
</span>
<button
type="button"
class="it-delete-btn text-[var(--text-secondary)] hover:text-red-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] rounded"
aria-label="Delete ${escapeHtml(c.name)}"
data-id="${c.id}"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
`;
listEl.appendChild(li);
});
// Attach delete listeners
listEl.querySelectorAll(".it-delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const id = (e.currentTarget as HTMLElement).getAttribute("data-id");
if (id) deleteCombatant(id);
});
});
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function addCombatant() {
const name = nameInput.value.trim();
const initiativeVal = initiativeInput.value;
if (!name) {
nameInput.focus();
return;
}
let initiative = parseInt(initiativeVal, 10);
if (Number.isNaN(initiative)) {
const modifier = parseInt(modifierInput.value, 10) || 0;
initiative = rollInitiative(modifier);
initiativeInput.value = String(initiative);
}
const combatants = loadCombatants();
combatants.push({ id: generateId(), name, initiative });
saveCombatants(combatants);
// Reset form
nameInput.value = "";
initiativeInput.value = "";
modifierInput.value = "0";
nameInput.focus();
activeIndex = 0;
updateVisibility();
renderList();
}
function deleteCombatant(id: string) {
const combatants = loadCombatants().filter((c) => c.id !== id);
saveCombatants(combatants);
// Adjust active index if needed
if (activeIndex >= combatants.length) {
activeIndex = 0;
}
updateVisibility();
renderList();
}
function nextTurn() {
const combatants = loadCombatants();
if (combatants.length === 0) return;
activeIndex = getNextActiveIndex(activeIndex, combatants.length);
renderList();
}
function clearAll() {
sessionStorage.removeItem(STORAGE_KEY);
activeIndex = 0;
updateVisibility();
renderList();
}
function handleRoll() {
const modifier = parseInt(modifierInput.value, 10) || 0;
const result = rollInitiative(modifier);
initiativeInput.value = String(result);
}
// Event listeners
rollBtn?.addEventListener("click", handleRoll);
addBtn?.addEventListener("click", addCombatant);
nextBtn?.addEventListener("click", nextTurn);
clearBtn?.addEventListener("click", clearAll);
nameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addCombatant();
}
});
initiativeInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addCombatant();
}
});
// Init
updateVisibility();
renderList();
</script>
+77
View File
@@ -0,0 +1,77 @@
---
import DmButton from './DmButton.astro';
---
<div class="w-full">
<div class="flex items-center justify-between mb-3">
<span id="notes-saved-indicator" class="text-sm text-[var(--accent)] opacity-0 transition-opacity duration-300">
Saved
</span>
<span id="notes-char-count" class="text-sm text-[var(--text-secondary)]">0 chars</span>
</div>
<textarea
id="notes-textarea"
class="w-full h-64 resize-y rounded-xl bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] p-4 hover:border-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-shadow duration-150"
placeholder="Type your notes here..."
></textarea>
<div class="mt-4 flex justify-end">
<DmButton id="notes-clear-btn" variant="secondary" size="sm">Clear</DmButton>
</div>
</div>
<script>
import { saveNotes, loadNotes, clearNotes } from '@/lib/client/notes';
const textarea = document.getElementById('notes-textarea') as HTMLTextAreaElement;
const savedIndicator = document.getElementById('notes-saved-indicator') as HTMLSpanElement;
const charCount = document.getElementById('notes-char-count') as HTMLSpanElement;
const clearBtn = document.getElementById('notes-clear-btn') as HTMLButtonElement;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let hideIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
function showSavedIndicator() {
savedIndicator.classList.remove('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
hideIndicatorTimer = setTimeout(() => {
savedIndicator.classList.add('opacity-0');
}, 1500);
}
function updateCharCount() {
const count = textarea.value.length;
charCount.textContent = `${count} char${count === 1 ? '' : 's'}`;
}
function loadSavedNotes() {
const saved = loadNotes();
textarea.value = saved;
updateCharCount();
}
function handleInput() {
updateCharCount();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
saveNotes(textarea.value);
showSavedIndicator();
}, 500);
}
function handleClear() {
textarea.value = '';
clearNotes();
updateCharCount();
savedIndicator.classList.add('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
if (debounceTimer) clearTimeout(debounceTimer);
textarea.focus();
}
textarea.addEventListener('input', handleInput);
clearBtn.addEventListener('click', handleClear);
loadSavedNotes();
</script>
+435
View File
@@ -0,0 +1,435 @@
---
import DmCard from "./DmCard.astro";
import DmInput from "./DmInput.astro";
import DmButton from "./DmButton.astro";
---
<div id="open5e-reference" class="w-full max-w-3xl mx-auto">
<!-- Tabs -->
<div class="flex gap-1 mb-4" role="tablist" aria-label="Open5e разделы">
<button
id="o5-tab-monsters"
type="button"
role="tab"
aria-selected="true"
aria-controls="o5-panel-monsters"
class="flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="monsters"
>
Монстры
</button>
<button
id="o5-tab-spells"
type="button"
role="tab"
aria-selected="false"
aria-controls="o5-panel-spells"
class="flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--bg-card)] text-[var(--text-secondary)] border border-[var(--border-color)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="spells"
>
Заклинания
</button>
</div>
<!-- Search and filters -->
<DmCard padding="md" class="mb-4">
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<DmInput
id="o5-search"
type="text"
placeholder="Поиск..."
label=""
/>
</div>
<div class="sm:w-40">
<select
id="o5-filter"
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-colors"
>
<option value="">Все</option>
</select>
</div>
</div>
</DmCard>
<!-- Loading state -->
<div id="o5-loading" class="hidden text-center py-8">
<div class="inline-block w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin"></div>
<p class="mt-2 text-sm text-[var(--text-secondary)]">Загрузка...</p>
</div>
<!-- Error state -->
<div id="o5-error" class="hidden rounded-xl bg-red-500/10 border border-red-500/30 p-4 mb-4">
<p class="text-sm text-red-400" id="o5-error-text"></p>
</div>
<!-- Empty state -->
<div id="o5-empty" class="hidden rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-8 text-center">
<p class="text-[var(--text-secondary)] text-sm">Ничего не найдено. Попробуйте изменить запрос или фильтры.</p>
</div>
<!-- Results list -->
<div id="o5-results" class="space-y-2 mb-4" role="tabpanel" aria-label="Результаты поиска"></div>
<!-- Pagination -->
<div id="o5-pagination" class="hidden flex items-center justify-between gap-3">
<DmButton id="o5-prev" variant="secondary" size="sm" disabled>
← Назад
</DmButton>
<span id="o5-page-info" class="text-sm text-[var(--text-secondary)] font-medium"></span>
<DmButton id="o5-next" variant="secondary" size="sm" disabled>
Вперёд →
</DmButton>
</div>
<!-- Detail modal / panel -->
<div
id="o5-detail-overlay"
class="hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center p-4 pt-12"
aria-modal="true"
role="dialog"
>
<DmCard padding="lg" class="w-full max-w-2xl max-h-[80vh] overflow-y-auto relative">
<button
id="o5-detail-close"
type="button"
class="absolute top-4 right-4 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] rounded-lg p-1"
aria-label="Закрыть"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</button>
<div id="o5-detail-content"></div>
</DmCard>
</div>
</div>
<script>
import { Open5eUIManager } from "@/lib/client/open5e-ui";
const CR_OPTIONS = [
{ value: "", label: "Все ОП" },
{ value: "0.125", label: "1/8" },
{ value: "0.25", label: "1/4" },
{ value: "0.5", label: "1/2" },
{ value: "1", label: "1" },
{ value: "2", label: "2" },
{ value: "3", label: "3" },
{ value: "5", label: "5" },
{ value: "10", label: "10" },
{ value: "15", label: "15" },
{ value: "20", label: "20" },
{ value: "30", label: "30" },
];
const LEVEL_OPTIONS = [
{ value: "", label: "Все уровни" },
{ value: "0", label: "Заговор" },
{ value: "1", label: "1-й" },
{ value: "2", label: "2-й" },
{ value: "3", label: "3-й" },
{ value: "4", label: "4-й" },
{ value: "5", label: "5-й" },
{ value: "6", label: "6-й" },
{ value: "7", label: "7-й" },
{ value: "8", label: "8-й" },
{ value: "9", label: "9-й" },
];
const searchInput = document.getElementById("o5-search") as HTMLInputElement;
const filterSelect = document.getElementById("o5-filter") as HTMLSelectElement;
const resultsContainer = document.getElementById("o5-results")!;
const loadingEl = document.getElementById("o5-loading")!;
const errorEl = document.getElementById("o5-error")!;
const errorTextEl = document.getElementById("o5-error-text")!;
const emptyEl = document.getElementById("o5-empty")!;
const paginationEl = document.getElementById("o5-pagination")!;
const prevBtn = document.getElementById("o5-prev")!;
const nextBtn = document.getElementById("o5-next")!;
const pageInfoEl = document.getElementById("o5-page-info")!;
const detailOverlay = document.getElementById("o5-detail-overlay")!;
const detailContent = document.getElementById("o5-detail-content")!;
const detailClose = document.getElementById("o5-detail-close")!;
const tabMonsters = document.getElementById("o5-tab-monsters")!;
const tabSpells = document.getElementById("o5-tab-spells")!;
interface Monster {
key: string;
name: string;
challenge_rating_decimal?: string;
type?: string;
hit_points?: number;
armor_class?: number;
speed?: Record<string, unknown>;
actions?: Array<{ name: string; desc?: string }>;
}
interface Spell {
key: string;
name: string;
level?: number;
school?: string;
casting_time?: string;
range?: string;
components?: string;
duration?: string;
desc?: string;
}
const manager = new Open5eUIManager(render);
function buildFilterOptions() {
const options = manager.state.tab === "monsters" ? CR_OPTIONS : LEVEL_OPTIONS;
filterSelect.innerHTML = options
.map((o) => `<option value="${o.value}">${o.label}</option>`)
.join("");
}
function updateTabs() {
const isMonsters = manager.state.tab === "monsters";
tabMonsters.setAttribute("aria-selected", String(isMonsters));
tabSpells.setAttribute("aria-selected", String(!isMonsters));
if (isMonsters) {
tabMonsters.className = "flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
tabSpells.className = "flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--bg-card)] text-[var(--text-secondary)] border border-[var(--border-color)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
} else {
tabMonsters.className = "flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--bg-card)] text-[var(--text-secondary)] border border-[var(--border-color)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
tabSpells.className = "flex-1 px-4 py-2.5 text-sm font-semibold rounded-lg transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
}
}
function renderMonsterCard(monster: Monster): string {
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-4 hover:border-[var(--accent)] transition-colors group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${monster.key}" role="button" tabindex="0">
<div class="min-w-0">
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[var(--accent)] transition-colors truncate">
${escapeHtml(monster.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(monster.type || "—")} · ОП ${cr}
</p>
</div>
<div class="shrink-0 text-right">
<p class="text-xs text-[var(--text-secondary)]">КЗ ${monster.armor_class ?? "—"}</p>
<p class="text-xs text-[var(--text-secondary)]">ХП ${monster.hit_points ?? "—"}</p>
</div>
</div>
`;
}
function renderSpellCard(spell: Spell): string {
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-color)] p-4 hover:border-[var(--accent)] transition-colors group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${spell.key}" role="button" tabindex="0">
<div class="min-w-0">
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[var(--accent)] transition-colors truncate">
${escapeHtml(spell.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(spell.school || "—")} · ${level}
</p>
</div>
</div>
`;
}
function renderMonsterDetail(monster: Monster): string {
const cr = Open5eUIManager.formatCr(monster.challenge_rating_decimal ?? "");
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(monster.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(monster.type || "—")} · ОП ${cr}</p>
<div class="grid grid-cols-3 gap-3 mt-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-lg font-bold text-[var(--accent)]">${monster.hit_points ?? "—"}</p>
<p class="text-xs text-[var(--text-secondary)]">ХП</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-lg font-bold text-[var(--accent)]">${monster.armor_class ?? "—"}</p>
<p class="text-xs text-[var(--text-secondary)]">КЗ</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-lg font-bold text-[var(--accent)]">${cr}</p>
<p class="text-xs text-[var(--text-secondary)]">ОП</p>
</div>
</div>
`;
if (monster.speed && typeof monster.speed === "object") {
const speeds = Object.entries(monster.speed)
.filter(([, v]) => v)
.map(([k, v]) => `${k} ${v} фт.`)
.join(", ");
if (speeds) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)]">Скорость</p><p class="text-sm text-[var(--text-primary)]">${escapeHtml(speeds)}</p></div>`;
}
}
if (Array.isArray(monster.actions) && monster.actions.length > 0) {
html += `<div class="mt-4"><p class="text-sm font-semibold text-[var(--text-primary)] mb-2">Действия</p>`;
for (const action of monster.actions) {
html += `
<div class="mb-2">
<p class="text-sm font-medium text-[var(--accent)]">${escapeHtml(action.name)}</p>
<p class="text-sm text-[var(--text-secondary)]">${escapeHtml(action.desc || "")}</p>
</div>
`;
}
html += `</div>`;
}
return html;
}
function renderSpellDetail(spell: Spell): string {
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(spell.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(spell.school || "—")} · ${level}</p>
<div class="grid grid-cols-2 gap-3 mt-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time || "—")}</p>
<p class="text-xs text-[var(--text-secondary)]">Время накладывания</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.range || "—")}</p>
<p class="text-xs text-[var(--text-secondary)]">Дистанция</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.components || "—")}</p>
<p class="text-xs text-[var(--text-secondary)]">Компоненты</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.duration || "—")}</p>
<p class="text-xs text-[var(--text-secondary)]">Длительность</p>
</div>
</div>
`;
if (spell.desc) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(spell.desc)}</p></div>`;
}
return html;
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function render() {
const state = manager.state;
updateTabs();
loadingEl.classList.toggle("hidden", !state.loading || state.results.length > 0);
errorEl.classList.toggle("hidden", !state.error);
if (state.error) errorTextEl.textContent = state.error;
emptyEl.classList.toggle("hidden", state.results.length > 0 || state.loading || !!state.error);
const paginated = manager.getPaginatedResults();
resultsContainer.innerHTML = "";
if (paginated.length > 0) {
for (const item of paginated) {
const cardHtml = state.tab === "monsters"
? renderMonsterCard(item)
: renderSpellCard(item);
const wrapper = document.createElement("div");
wrapper.innerHTML = cardHtml;
const card = wrapper.firstElementChild as HTMLElement;
if (card) {
const key = item.key;
card.addEventListener("click", () => manager.selectItem(key));
card.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
manager.selectItem(key);
}
});
resultsContainer.appendChild(card);
}
}
}
if (state.totalPages > 1) {
paginationEl.classList.remove("hidden");
pageInfoEl.textContent = `Страница ${state.page} из ${state.totalPages}`;
(prevBtn as HTMLButtonElement).disabled = state.page <= 1;
(nextBtn as HTMLButtonElement).disabled = state.page >= state.totalPages;
} else {
paginationEl.classList.add("hidden");
}
if (state.selectedItem) {
detailContent.innerHTML = state.tab === "monsters"
? renderMonsterDetail(state.selectedItem)
: renderSpellDetail(state.selectedItem);
detailOverlay.classList.remove("hidden");
detailClose.focus();
} else {
detailOverlay.classList.add("hidden");
}
}
function closeDetail() {
manager.clearSelectedItem();
}
tabMonsters.addEventListener("click", () => {
manager.setTab("monsters");
buildFilterOptions();
});
tabSpells.addEventListener("click", () => {
manager.setTab("spells");
buildFilterOptions();
});
searchInput.addEventListener("input", () => {
manager.setQueryDebounced(searchInput.value.trim());
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
manager.setQuery(searchInput.value.trim());
}
});
filterSelect.addEventListener("change", () => {
const value = filterSelect.value;
if (manager.state.tab === "monsters") {
manager.setMonsterCrFilter(value);
} else {
manager.setSpellLevelFilter(value);
}
manager.search();
});
prevBtn.addEventListener("click", () => {
manager.prevPage();
});
nextBtn.addEventListener("click", () => {
manager.nextPage();
});
detailClose.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", (e) => {
if (e.target === detailOverlay) closeDetail();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !detailOverlay.classList.contains("hidden")) {
closeDetail();
}
});
buildFilterOptions();
manager.search();
</script>
+9
View File
@@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
+21
View File
@@ -0,0 +1,21 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const db = drizzle(pool);
async function runMigrations() {
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations completed.');
await pool.end();
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
import { pgTable, serial, varchar, timestamp, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
vkId: varchar('vk_id', { length: 255 }),
yandexId: varchar('yandex_id', { length: 255 }),
email: varchar('email', { length: 255 }),
name: varchar('name', { length: 255 }).notNull(),
avatar: varchar('avatar', { length: 500 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const sessions = pgTable('sessions', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id).notNull(),
token: varchar('token', { length: 500 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
});
+16
View File
@@ -1 +1,17 @@
/// <reference path="../.astro/types.d.ts" />
interface User {
id: number;
vkId: string | null;
yandexId: string | null;
email: string | null;
name: string;
avatar: string | null;
createdAt: Date;
}
declare namespace App {
interface Locals {
user: User | null;
}
}
+16
View File
@@ -0,0 +1,16 @@
export const dmTranslations = {
title: "DM Dashboard",
subtitle: "Инструменты для мастера подземелий",
dice: "Кубики",
initiative: "Инициатива",
reference: "Справочник",
notes: "Заметки",
backToRandify: "← Назад к Randify",
footer: "Часть Randify — генераторов случайных значений",
anchorDice: "Кубики",
anchorInitiative: "Инициатива",
anchorReference: "Справочник",
anchorNotes: "Заметки",
} as const;
export type DmT = typeof dmTranslations;
+4
View File
@@ -81,6 +81,8 @@ export const translations = {
catDecisionMaking: "Decision Making",
catCreative: "Creative",
catUtility: "Utility",
dmDashboard: "DM Dashboard",
dmDashboardDesc: "Tools for dungeon masters — dice, initiative, reference, and notes.",
notFoundTitle: "Page Not Found",
notFoundMessage:
@@ -192,6 +194,8 @@ export const translations = {
catDecisionMaking: "Принятие решений",
catCreative: "Творчество",
catUtility: "Утилиты",
dmDashboard: "Для мастера",
dmDashboardDesc: "Инструменты для мастера подземелий — кубики, инициатива, справочник и заметки.",
notFoundTitle: "Страница не найдена",
notFoundMessage:
+49
View File
@@ -0,0 +1,49 @@
---
import BaseLayout from "./BaseLayout.astro";
import DmHeader from "../components/dm/DmHeader.astro";
import AuthPanel from "../components/dm/AuthPanel.astro";
import { dmTranslations as T } from "../i18n/dm-translations";
import "../styles/dm-theme.css";
interface Props {
title?: string;
description?: string;
}
const {
title = T.title,
description = "Dungeon Master Dashboard",
} = Astro.props;
---
<BaseLayout title={title} description={description}>
<div class="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)] flex flex-col">
<DmHeader title={title}>
<a
href="/"
class="hidden sm:inline-flex items-center text-sm text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] rounded-md px-2 py-1"
>
{T.backToRandify}
</a>
<AuthPanel />
</DmHeader>
<main class="flex-1">
<slot />
</main>
<footer class="w-full border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/60 backdrop-blur-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<span class="text-sm text-[var(--text-secondary)]">
{T.footer}
</span>
<a
href="/"
class="text-sm text-[var(--accent)] hover:text-[var(--accent-light)] active:text-[var(--accent-dark)] transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] rounded-md px-1 py-0.5"
>
{T.backToRandify}
</a>
</div>
</footer>
</div>
</BaseLayout>
+38
View File
@@ -0,0 +1,38 @@
import { SignJWT, jwtVerify } from 'jose';
import type { APIContext } from 'astro';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function createToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(SECRET);
}
export async function verifyToken(token: string): Promise<{ sub: string }> {
const { payload } = await jwtVerify(token, SECRET, {
clockTolerance: 60,
});
if (!payload.sub || typeof payload.sub !== 'string') {
throw new Error('Invalid token payload');
}
return { sub: payload.sub };
}
export function setAuthCookie(token: string, context: APIContext): void {
context.cookies.set('auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
}
export function clearAuthCookie(context: APIContext): void {
context.cookies.delete('auth_token', {
path: '/',
});
}
+73
View File
@@ -0,0 +1,73 @@
import { SignJWT, jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(import.meta.env.JWT_SECRET || '');
export function generateCodeVerifier(): string {
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(buffer: Uint8Array): string {
let binary = '';
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export const vkOAuthConfig = {
clientId: import.meta.env.VK_CLIENT_ID || '',
clientSecret: import.meta.env.VK_CLIENT_SECRET || '',
authUrl: 'https://id.vk.com/oauth2/auth',
tokenUrl: 'https://id.vk.ru/oauth2/auth',
scope: 'email phone',
};
export const yandexOAuthConfig = {
clientId: import.meta.env.YANDEX_CLIENT_ID || '',
clientSecret: import.meta.env.YANDEX_CLIENT_SECRET || '',
authUrl: 'https://oauth.yandex.com/authorize',
tokenUrl: 'https://oauth.yandex.com/token',
scope: 'login:email login:info login:avatar',
};
export async function createSessionToken(userId: number): Promise<string> {
return new SignJWT({ sub: String(userId) })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET);
}
export async function verifySessionToken(token: string): Promise<{ userId: number } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 });
if (!payload.sub) return null;
return { userId: Number(payload.sub) };
} catch {
return null;
}
}
export const COOKIE_NAME = 'session';
export const VERIFIER_COOKIE_NAME = 'oauth_verifier';
export function getCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return match ? decodeURIComponent(match[1]) : null;
}
+32
View File
@@ -0,0 +1,32 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db/client';
import { sessions } from '@/db/schema';
export async function createSession(userId: number, token: string) {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await db.insert(sessions).values({
userId,
token,
expiresAt,
});
}
export async function getSession(token: string) {
const result = await db
.select()
.from(sessions)
.where(eq(sessions.token, token))
.limit(1);
const session = result[0];
if (!session) return null;
if (new Date() > session.expiresAt) return null;
return session;
}
export async function deleteSession(token: string) {
await db.delete(sessions).where(eq(sessions.token, token));
}
+31
View File
@@ -0,0 +1,31 @@
export interface AuthUser {
name: string;
avatar: string | null;
}
export type AuthPanelState =
| { type: 'guest' }
| { type: 'authenticated'; user: AuthUser };
export function getAuthPanelState(user: AuthUser | null): AuthPanelState {
if (!user) {
return { type: 'guest' };
}
return { type: 'authenticated', user };
}
export interface AuthPanelLink {
href: string;
label: string;
style: 'vk' | 'yandex' | 'logout';
}
export function getAuthLinks(state: AuthPanelState): AuthPanelLink[] {
if (state.type === 'guest') {
return [
{ href: '/api/auth/login/vk', label: 'Войти через VK', style: 'vk' },
{ href: '/api/auth/login/yandex', label: 'Войти через Яндекс', style: 'yandex' },
];
}
return [{ href: '/api/auth/logout', label: 'Выйти', style: 'logout' }];
}
+54
View File
@@ -0,0 +1,54 @@
import { randomInt } from "./random";
export interface DiceNotation {
count: number;
sides: number;
modifier: number;
}
export interface DiceRollResult {
rolls: number[];
total: number;
modifier: number;
}
export function parseDiceNotation(notation: string): DiceNotation {
const trimmed = notation.trim().toLowerCase();
if (!trimmed) {
throw new Error("Empty dice notation");
}
const match = trimmed.match(/^(\d*)\s*d\s*(\d+)\s*([+-]\s*\d+)?$/);
if (!match) {
throw new Error(`Invalid dice notation: ${notation}`);
}
const count = match[1] === "" ? 1 : parseInt(match[1], 10);
const sides = parseInt(match[2], 10);
const modifier = match[3] ? parseInt(match[3].replace(/\s/g, ""), 10) : 0;
if (count < 1 || sides < 1) {
throw new Error("Dice count and sides must be at least 1");
}
return { count, sides, modifier };
}
export function rollDice(
count: number,
sides: number,
modifier: number,
): DiceRollResult {
if (count < 1 || sides < 1) {
throw new Error("Dice count and sides must be at least 1");
}
const rolls: number[] = [];
for (let i = 0; i < count; i++) {
rolls.push(randomInt(1, sides));
}
const total = rolls.reduce((sum, roll) => sum + roll, 0) + modifier;
return { rolls, total, modifier };
}
+20
View File
@@ -0,0 +1,20 @@
import { randomInt } from "./random";
export interface Combatant {
id: string;
name: string;
initiative: number;
}
export function sortByInitiative(combatants: Combatant[]): Combatant[] {
return [...combatants].sort((a, b) => b.initiative - a.initiative);
}
export function getNextActiveIndex(currentIndex: number, total: number): number {
if (total === 0) return 0;
return (currentIndex + 1) % total;
}
export function rollInitiative(modifier: number = 0): number {
return randomInt(1, 20) + modifier;
}
+13
View File
@@ -0,0 +1,13 @@
const STORAGE_KEY = 'dm-notes';
export function saveNotes(content: string): void {
localStorage.setItem(STORAGE_KEY, content);
}
export function loadNotes(): string {
return localStorage.getItem(STORAGE_KEY) ?? '';
}
export function clearNotes(): void {
localStorage.removeItem(STORAGE_KEY);
}
+188
View File
@@ -0,0 +1,188 @@
import {
searchMonsters,
searchSpells,
getMonster,
getSpell,
} from "@/lib/open5e/client";
export type Tab = "monsters" | "spells";
export interface Open5eItem {
key: string;
name: string;
}
export interface UIState {
tab: Tab;
query: string;
monsterCrFilter: string;
spellLevelFilter: string;
page: number;
pageSize: number;
results: Open5eItem[];
totalPages: number;
loading: boolean;
error: string | null;
selectedItem: Open5eItem | null;
}
export class Open5eUIManager {
state: UIState;
onUpdate?: (state: UIState) => void;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor(onUpdate?: (state: UIState) => void) {
this.onUpdate = onUpdate;
this.state = {
tab: "monsters",
query: "",
monsterCrFilter: "",
spellLevelFilter: "",
page: 1,
pageSize: 10,
results: [],
totalPages: 1,
loading: false,
error: null,
selectedItem: null,
};
}
private notify() {
this.onUpdate?.({ ...this.state });
}
async setTab(tab: Tab): Promise<void> {
this.state.tab = tab;
this.state.page = 1;
this.state.selectedItem = null;
this.notify();
await this.search();
}
setQuery(query: string): Promise<void> {
this.state.query = query;
this.state.page = 1;
this.notify();
return this.search();
}
setQueryDebounced(query: string): void {
this.state.query = query;
this.state.page = 1;
this.notify();
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.search();
}, 300);
}
setMonsterCrFilter(cr: string): void {
this.state.monsterCrFilter = cr;
this.state.page = 1;
this.notify();
}
setSpellLevelFilter(level: string): void {
this.state.spellLevelFilter = level;
this.state.page = 1;
this.notify();
}
async search(): Promise<void> {
this.state.loading = true;
this.state.error = null;
this.notify();
try {
let results: Open5eItem[] = [];
if (this.state.tab === "monsters") {
const filters = this.state.monsterCrFilter
? { challenge_rating_decimal: this.state.monsterCrFilter }
: undefined;
results = await searchMonsters(this.state.query, filters);
} else {
const filters = this.state.spellLevelFilter
? { level: this.state.spellLevelFilter }
: undefined;
results = await searchSpells(this.state.query, filters);
}
this.state.results = results;
this.state.totalPages = Math.max(
1,
Math.ceil(results.length / this.state.pageSize)
);
} catch (err: unknown) {
this.state.error = err instanceof Error ? err.message : "Unknown error";
this.state.results = [];
this.state.totalPages = 1;
} finally {
this.state.loading = false;
this.notify();
}
}
getPaginatedResults(): Open5eItem[] {
const start = (this.state.page - 1) * this.state.pageSize;
const end = start + this.state.pageSize;
return this.state.results.slice(start, end);
}
nextPage(): void {
if (this.state.page < this.state.totalPages) {
this.state.page++;
this.notify();
}
}
prevPage(): void {
if (this.state.page > 1) {
this.state.page--;
this.notify();
}
}
async selectItem(key: string): Promise<void> {
this.state.loading = true;
this.notify();
try {
if (this.state.tab === "monsters") {
this.state.selectedItem = await getMonster(key);
} else {
this.state.selectedItem = await getSpell(key);
}
} catch (err: unknown) {
this.state.error = err instanceof Error ? err.message : "Failed to load details";
} finally {
this.state.loading = false;
this.notify();
}
}
clearSelectedItem(): void {
this.state.selectedItem = null;
this.notify();
}
static formatCr(cr: string): string {
const crMap: Record<string, string> = {
"0.125": "1/8",
"0.25": "1/4",
"0.5": "1/2",
"1.00": "1",
"2.00": "2",
"3.00": "3",
"5.00": "5",
"10.00": "10",
"15.00": "15",
"20.00": "20",
"30.00": "30",
};
return crMap[cr] ?? String(parseFloat(cr));
}
static formatSpellLevel(level: number): string {
if (level === 0) return "Заговор";
return `${level}-й уровень`;
}
}
+37
View File
@@ -0,0 +1,37 @@
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export function getCached<T>(key: string): T | null {
if (typeof localStorage === "undefined") return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const entry: CacheEntry<T> = JSON.parse(raw);
if (Date.now() - entry.timestamp > ONE_DAY_MS) {
localStorage.removeItem(key);
return null;
}
return entry.data;
} catch {
return null;
}
}
function safeStorageSet(key: string, value: string): boolean {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
export function setCached<T>(key: string, data: T): void {
if (typeof localStorage === "undefined") return;
const entry: CacheEntry<T> = { data, timestamp: Date.now() };
safeStorageSet(key, JSON.stringify(entry));
}
+131
View File
@@ -0,0 +1,131 @@
import { getCached, setCached } from "./cache";
const API_BASE = "https://api.open5e.com/v2";
export interface Monster {
name: string;
key: string;
challenge_rating_decimal: string;
type: string;
hit_points: number;
armor_class: number;
}
export interface Spell {
name: string;
key: string;
level: number;
school: string;
}
interface SearchFilters {
[key: string]: string;
}
async function apiFetch<T>(url: string): Promise<T> {
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`${res.status}: ${res.statusText}`);
}
return res.json();
}
function buildUrl(endpoint: string, query?: string, filters?: SearchFilters): string {
const url = new URL(endpoint, API_BASE);
if (query) {
url.searchParams.set("name__icontains", query);
}
if (filters) {
for (const [key, value] of Object.entries(filters)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
export async function searchMonsters(
query: string,
filters?: SearchFilters
): Promise<Monster[]> {
const cacheKey = `open5e:monsters:${query}`;
const cached = getCached<Monster[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("/creatures/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set(
"fields",
"name,key,challenge_rating_decimal,type,hit_points,armor_class"
);
try {
const data = await apiFetch<{ results: Monster[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<Monster[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getMonster(key: string): Promise<Monster> {
const cacheKey = `open5e:monster:${key}`;
const cached = getCached<Monster>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}/creatures/${key}/`;
try {
const data = await apiFetch<Monster>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<Monster>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function searchSpells(
query: string,
filters?: SearchFilters
): Promise<Spell[]> {
const cacheKey = `open5e:spells:${query}`;
const cached = getCached<Spell[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("/spells/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set("fields", "name,key,level,school");
try {
const data = await apiFetch<{ results: Spell[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<Spell[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getSpell(key: string): Promise<Spell> {
const cacheKey = `open5e:spell:${key}`;
const cached = getCached<Spell>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}/spells/${key}/`;
try {
const data = await apiFetch<Spell>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<Spell>(cacheKey);
if (stale) return stale;
throw err;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { defineMiddleware } from 'astro:middleware';
import { verifyToken } from '@/lib/auth/jwt';
import { getSession } from '@/lib/auth/session';
import { db } from '@/db/client';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
const token = context.cookies.get('auth_token')?.value;
if (!token) {
return next();
}
try {
await verifyToken(token);
const session = await getSession(token);
if (!session) {
return next();
}
const userResult = await db
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
const user = userResult[0];
if (user) {
context.locals.user = user;
}
} catch {
/* ignore invalid tokens */
}
return next();
});
+1
View File
@@ -18,6 +18,7 @@ const notFoundSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout title={`${T.notFoundTitle} | Randify`} description={T.notFoundMessage}>
<script type="application/ld+json" set:html={JSON.stringify(notFoundSchema)} slot="head" />
+1
View File
@@ -15,6 +15,7 @@ const aboutSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout title={`${T.aboutTitle} | Randify`} description={T.aboutMission}>
<script type="application/ld+json" set:html={JSON.stringify(aboutSchema)} slot="head" />
+119
View File
@@ -0,0 +1,119 @@
import type { APIRoute } from 'astro';
import { eq } from 'drizzle-orm';
import { db } from '@/db/client';
import { users, sessions } from '@/db/schema';
import {
vkOAuthConfig,
VERIFIER_COOKIE_NAME,
getCookieValue,
createSessionToken,
COOKIE_NAME,
} from '@/lib/auth/oauth';
export const prerender = false;
export const GET: APIRoute = async ({ url, request }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const cookieHeader = request.headers.get('cookie');
const verifier = getCookieValue(cookieHeader, VERIFIER_COOKIE_NAME);
const storedState = getCookieValue(cookieHeader, 'oauth_state');
if (!code || !state || !verifier || state !== storedState) {
return new Response(JSON.stringify({ error: 'Invalid or missing parameters' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const redirectUri = `${url.origin}/api/auth/callback/vk`;
const tokenRes = await fetch(vkOAuthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: vkOAuthConfig.clientId,
client_secret: vkOAuthConfig.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
});
if (!tokenRes.ok) {
return new Response(JSON.stringify({ error: 'Token exchange failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
const userIdStr = tokenData.user_id || tokenData.user?.user_id;
if (!accessToken || !userIdStr) {
return new Response(JSON.stringify({ error: 'Invalid token response' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const userInfoRes = await fetch(
`https://api.vk.com/method/users.get?user_ids=${userIdStr}&fields=photo_200,email&access_token=${accessToken}&v=5.199`
);
const userInfoData = await userInfoRes.json();
const vkUser = userInfoData.response?.[0];
if (!vkUser) {
return new Response(JSON.stringify({ error: 'Failed to fetch user info' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const email = tokenData.email || null;
const name = `${vkUser.first_name || ''} ${vkUser.last_name || ''}`.trim();
const avatar = vkUser.photo_200 || null;
const existingUsers = await db.select().from(users).where(eq(users.vkId, String(userIdStr)));
let user;
if (existingUsers.length > 0) {
user = existingUsers[0];
await db
.update(users)
.set({ name, email: email || user.email, avatar: avatar || user.avatar })
.where(eq(users.id, user.id));
} else {
const inserted = await db
.insert(users)
.values({ vkId: String(userIdStr), email, name, avatar })
.returning();
user = inserted[0];
}
const sessionToken = await createSessionToken(user.id);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await db.insert(sessions).values({
userId: user.id,
token: sessionToken,
expiresAt,
});
const cookies = [
`${COOKIE_NAME}=${sessionToken}; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/`,
`${VERIFIER_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`,
`oauth_state=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`,
];
return new Response(null, {
status: 302,
headers: {
Location: '/dm/',
'Set-Cookie': cookies.join(', '),
},
});
};
+120
View File
@@ -0,0 +1,120 @@
import type { APIRoute } from 'astro';
import { eq } from 'drizzle-orm';
import { db } from '@/db/client';
import { users, sessions } from '@/db/schema';
import {
yandexOAuthConfig,
VERIFIER_COOKIE_NAME,
getCookieValue,
createSessionToken,
COOKIE_NAME,
} from '@/lib/auth/oauth';
export const prerender = false;
export const GET: APIRoute = async ({ url, request }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const cookieHeader = request.headers.get('cookie');
const verifier = getCookieValue(cookieHeader, VERIFIER_COOKIE_NAME);
const storedState = getCookieValue(cookieHeader, 'oauth_state');
if (!code || !state || !verifier || state !== storedState) {
return new Response(JSON.stringify({ error: 'Invalid or missing parameters' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const redirectUri = `${url.origin}/api/auth/callback/yandex`;
const tokenRes = await fetch(yandexOAuthConfig.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${yandexOAuthConfig.clientId}:${yandexOAuthConfig.clientSecret}`)}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
});
if (!tokenRes.ok) {
return new Response(JSON.stringify({ error: 'Token exchange failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
if (!accessToken) {
return new Response(JSON.stringify({ error: 'Invalid token response' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const userInfoRes = await fetch('https://login.yandex.ru/info?format=json', {
headers: { Authorization: `OAuth ${accessToken}` },
});
const yandexUser = await userInfoRes.json();
if (!yandexUser || !yandexUser.id) {
return new Response(JSON.stringify({ error: 'Failed to fetch user info' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const email = yandexUser.default_email || yandexUser.emails?.[0] || null;
const name = [yandexUser.first_name, yandexUser.last_name].filter(Boolean).join(' ').trim() || yandexUser.login || yandexUser.display_name || 'Yandex User';
const avatar = yandexUser.default_avatar_id
? `https://avatars.yandex.net/get-yapic/${yandexUser.default_avatar_id}/islands-200`
: null;
const existingUsers = await db.select().from(users).where(eq(users.yandexId, String(yandexUser.id)));
let user;
if (existingUsers.length > 0) {
user = existingUsers[0];
await db
.update(users)
.set({ name, email: email || user.email, avatar: avatar || user.avatar })
.where(eq(users.id, user.id));
} else {
const inserted = await db
.insert(users)
.values({ yandexId: String(yandexUser.id), email, name, avatar })
.returning();
user = inserted[0];
}
const sessionToken = await createSessionToken(user.id);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await db.insert(sessions).values({
userId: user.id,
token: sessionToken,
expiresAt,
});
const cookies = [
`${COOKIE_NAME}=${sessionToken}; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/`,
`${VERIFIER_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`,
`oauth_state=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`,
];
return new Response(null, {
status: 302,
headers: {
Location: '/dm/',
'Set-Cookie': cookies.join(', '),
},
});
};
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { generateCodeVerifier, generateCodeChallenge, generateState, vkOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${import.meta.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`;
const params = new URLSearchParams({
client_id: vkOAuthConfig.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: vkOAuthConfig.scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const redirectUrl = `${vkOAuthConfig.authUrl}?${params.toString()}`;
const cookies = [
`${VERIFIER_COOKIE_NAME}=${encodeURIComponent(verifier)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`,
`oauth_state=${encodeURIComponent(state)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`,
];
return new Response(null, {
status: 302,
headers: {
Location: redirectUrl,
'Set-Cookie': cookies.join(', '),
},
});
};
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { generateCodeVerifier, generateCodeChallenge, generateState, yandexOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth';
export const prerender = false;
export const GET: APIRoute = async ({ url }) => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${import.meta.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`;
const params = new URLSearchParams({
client_id: yandexOAuthConfig.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: yandexOAuthConfig.scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
const redirectUrl = `${yandexOAuthConfig.authUrl}?${params.toString()}`;
const cookies = [
`${VERIFIER_COOKIE_NAME}=${encodeURIComponent(verifier)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`,
`oauth_state=${encodeURIComponent(state)}; HttpOnly; SameSite=Strict; Max-Age=600; Path=/`,
];
return new Response(null, {
status: 302,
headers: {
Location: redirectUrl,
'Set-Cookie': cookies.join(', '),
},
});
};
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
import { COOKIE_NAME } from '@/lib/auth/oauth';
export const prerender = false;
export const GET: APIRoute = async () => {
const cookies = [
`${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/`,
];
return new Response(null, {
status: 302,
headers: {
Location: '/dm/',
'Set-Cookie': cookies.join(', '),
},
});
};
+15
View File
@@ -0,0 +1,15 @@
import type { APIRoute } from "astro";
export const prerender = false;
export const GET: APIRoute = () => {
return new Response(
JSON.stringify({ status: "ok" }),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
);
};
+1
View File
@@ -31,6 +31,7 @@ export async function getStaticPaths() {
const { post, prev, next } = Astro.props;
const { Content } = await post.render();
---
export const prerender = true;
<BlogLayout
title={post.data.title}
+1
View File
@@ -31,6 +31,7 @@ const blogSchema = {
})),
};
---
export const prerender = true;
<BaseLayout title={pageTitle} description={pageDesc}>
<script
+90
View File
@@ -0,0 +1,90 @@
---
import DmLayout from "@/layouts/DmLayout.astro";
import DmCard from "@/components/dm/DmCard.astro";
import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
---
<DmLayout>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Subtitle -->
<p class="text-[var(--text-secondary)] text-base mb-4">
{T.subtitle}
</p>
<!-- Anchor Navigation -->
<nav aria-label="Инструменты" class="mb-8">
<ul class="flex flex-wrap gap-2">
{[
{ id: "dice", label: T.anchorDice },
{ id: "initiative", label: T.anchorInitiative },
{ id: "reference", label: T.anchorReference },
{ id: "notes", label: T.anchorNotes },
].map((item) => (
<li>
<a
href={`#${item.id}`}
class="inline-flex items-center px-3 py-1.5 rounded-full bg-[var(--bg-card)] border border-[var(--border-color)] text-[var(--text-secondary)] text-sm font-medium hover:bg-[var(--accent)]/10 hover:text-[var(--accent)] hover:border-[var(--accent)]/30 active:bg-[var(--accent)]/20 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Dice Roller -->
<section id="dice" aria-labelledby="dice-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="dice-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.dice}
</h2>
</div>
<DiceRoller />
</section>
<!-- Initiative Tracker -->
<section id="initiative" aria-labelledby="initiative-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="initiative-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.initiative}
</h2>
</div>
<InitiativeTracker />
</section>
<!-- Open5e Reference -->
<section id="reference" aria-labelledby="reference-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="reference-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.reference}
</h2>
</div>
<DmCard>
<Open5eReference />
</DmCard>
</section>
<!-- Notes Panel -->
<section id="notes" aria-labelledby="notes-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="notes-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.notes}
</h2>
</div>
<DmCard>
<NotesPanel />
</DmCard>
</section>
</div>
</div>
</DmLayout>
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "cards")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CardGenerator />
@@ -70,6 +70,7 @@ const breadcrumbSchema = {
],
};
---
export const prerender = true;
<BaseLayout title={title} description={description}>
<Fragment slot="head">
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "coin")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CoinGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "colors")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ColorGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "country")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CountryGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'date')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<DateGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "dice")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<DiceGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
const generator = findGenerator("emoji");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<EmojiGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("fontpair");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<FontPairGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("gradient");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<GradientGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("hash");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<HashGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'letter')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LetterGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "list")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ListGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lorem")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LoremGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lottery")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LotteryGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
const generator = findGenerator('magic8ball');
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<Magic8BallGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "meal")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<MealGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'names')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<NamesGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "numbers")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<NumberGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "palette")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<PaletteGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "password")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<PasswordGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'rps')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<RpsGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("shuffler");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ShufflerGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'teams')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<TeamsGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'time')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<TimeGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "uuid")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<UuidGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "weighted")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<WeightedGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "wheel")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<WheelSpinner />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'yesno')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<YesNoGenerator />
+53 -1
View File
@@ -18,6 +18,7 @@ const webSiteSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout>
<script
@@ -72,7 +73,58 @@ const webSiteSchema = {
<AdBanner size="leaderboard" />
<main class="mt-8">
<!-- DM Dashboard Promo -->
<a
href="/dm/"
class="group block mt-8 mb-4 rounded-xl border border-[#E87722]/30 bg-[#E87722]/5 hover:bg-[#E87722]/10 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E87722] focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<div class="flex items-center gap-4 px-5 py-4">
<div
class="inline-flex w-10 h-10 rounded-lg bg-[#E87722]/10 items-center justify-center text-[#E87722] shrink-0"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
</div>
<div>
<span class="text-sm font-semibold text-[#E87722] group-hover:text-[#F09040] transition-colors">
{T.dmDashboard}
</span>
<p class="text-sm text-zinc-400 mt-0.5">
{T.dmDashboardDesc}
</p>
</div>
<svg
class="ml-auto text-zinc-500 group-hover:text-[#E87722] transition-colors shrink-0"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
</div>
</a>
<main class="mt-4">
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows: 1fr;"
+1
View File
@@ -6,6 +6,7 @@ import type { Lang } from "@/i18n/translations";
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
---
export const prerender = true;
<BaseLayout title={`${T.privacyTitle} | Randify`} description={T.privacyIntro}>
<div class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
+2
View File
@@ -1,3 +1,5 @@
export const prerender = true;
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
+1
View File
@@ -18,6 +18,7 @@ const notFoundSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout title={`${T.notFoundTitle} | Randify`} description={T.notFoundMessage}>
<script type="application/ld+json" set:html={JSON.stringify(notFoundSchema)} slot="head" />
+1
View File
@@ -15,6 +15,7 @@ const aboutSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout title={`${T.aboutTitle} | Randify`} description={T.aboutMission}>
<script type="application/ld+json" set:html={JSON.stringify(aboutSchema)} slot="head" />
+1
View File
@@ -31,6 +31,7 @@ export async function getStaticPaths() {
const { post, prev, next } = Astro.props;
const { Content } = await post.render();
---
export const prerender = true;
<BlogLayout
title={post.data.title}
+1
View File
@@ -31,6 +31,7 @@ const blogSchema = {
})),
};
---
export const prerender = true;
<BaseLayout title={pageTitle} description={pageDesc}>
<script
+90
View File
@@ -0,0 +1,90 @@
---
import DmLayout from "@/layouts/DmLayout.astro";
import DmCard from "@/components/dm/DmCard.astro";
import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
---
<DmLayout>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Subtitle -->
<p class="text-[var(--text-secondary)] text-base mb-4">
{T.subtitle}
</p>
<!-- Anchor Navigation -->
<nav aria-label="Инструменты" class="mb-8">
<ul class="flex flex-wrap gap-2">
{[
{ id: "dice", label: T.anchorDice },
{ id: "initiative", label: T.anchorInitiative },
{ id: "reference", label: T.anchorReference },
{ id: "notes", label: T.anchorNotes },
].map((item) => (
<li>
<a
href={`#${item.id}`}
class="inline-flex items-center px-3 py-1.5 rounded-full bg-[var(--bg-card)] border border-[var(--border-color)] text-[var(--text-secondary)] text-sm font-medium hover:bg-[var(--accent)]/10 hover:text-[var(--accent)] hover:border-[var(--accent)]/30 active:bg-[var(--accent)]/20 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Dice Roller -->
<section id="dice" aria-labelledby="dice-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="dice-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.dice}
</h2>
</div>
<DiceRoller />
</section>
<!-- Initiative Tracker -->
<section id="initiative" aria-labelledby="initiative-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="initiative-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.initiative}
</h2>
</div>
<InitiativeTracker />
</section>
<!-- Open5e Reference -->
<section id="reference" aria-labelledby="reference-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="reference-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.reference}
</h2>
</div>
<DmCard>
<Open5eReference />
</DmCard>
</section>
<!-- Notes Panel -->
<section id="notes" aria-labelledby="notes-heading">
<div class="flex items-center gap-3 mb-3">
<span class="w-1 h-6 bg-[var(--accent)] rounded-full" aria-hidden="true"></span>
<h2 id="notes-heading" class="text-lg font-bold text-[var(--text-primary)]">
{T.notes}
</h2>
</div>
<DmCard>
<NotesPanel />
</DmCard>
</section>
</div>
</div>
</DmLayout>
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "cards")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CardGenerator />
@@ -74,6 +74,7 @@ const breadcrumbSchema = {
],
};
---
export const prerender = true;
<BaseLayout title={title} description={description}>
<Fragment slot="head">
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "coin")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CoinGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "colors")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ColorGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "country")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<CountryGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'date')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<DateGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "dice")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<DiceGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
const generator = findGenerator("emoji");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<EmojiGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("fontpair");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<FontPairGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("gradient");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<GradientGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("hash");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<HashGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'letter')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LetterGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "list")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ListGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lorem")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LoremGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "lottery")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<LotteryGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
const generator = findGenerator('magic8ball');
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<Magic8BallGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "meal")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<MealGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'names')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<NamesGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "numbers")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<NumberGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "palette")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<PaletteGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from "@/data/generators";
const generator = generators.find((g) => g.slug === "password")!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<PasswordGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'rps')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<RpsGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
const generator = findGenerator("shuffler");
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<ShufflerGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'teams')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<TeamsGenerator />
+1
View File
@@ -5,6 +5,7 @@ import { generators } from '@/data/generators';
const generator = generators.find((g) => g.slug === 'time')!;
---
export const prerender = true;
<GeneratorLayout generator={generator}>
<TimeGenerator />

Some files were not shown because too many files have changed in this diff Show More