feat(dm): add dashboard with oauth, dice, initiative, open5e, notes
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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: '/',
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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' }];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}-й уровень`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -31,6 +31,7 @@ const blogSchema = {
|
||||
})),
|
||||
};
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<BaseLayout title={pageTitle} description={pageDesc}>
|
||||
<script
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
|
||||
|
||||
const generator = findGenerator("emoji");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<EmojiGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("fontpair");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<FontPairGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("gradient");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<GradientGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("hash");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<HashGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
|
||||
|
||||
const generator = findGenerator('magic8ball');
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<Magic8BallGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("shuffler");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<ShufflerGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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;"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const prerender = true;
|
||||
|
||||
import rss from "@astrojs/rss";
|
||||
import { getCollection } from "astro:content";
|
||||
import type { APIContext } from "astro";
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -31,6 +31,7 @@ const blogSchema = {
|
||||
})),
|
||||
};
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<BaseLayout title={pageTitle} description={pageDesc}>
|
||||
<script
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
|
||||
|
||||
const generator = findGenerator("emoji");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<EmojiGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("fontpair");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<FontPairGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("gradient");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<GradientGenerator />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("hash");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<HashGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from '@/data/generators';
|
||||
|
||||
const generator = findGenerator('magic8ball');
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<Magic8BallGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findGenerator } from "@/data/generators";
|
||||
|
||||
const generator = findGenerator("shuffler");
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
<GeneratorLayout generator={generator}>
|
||||
<ShufflerGenerator />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user