feat: Add HTML export, perception, speed and improve Status tab layout
- Add HTML character sheet export function - Add Perception calculation to Status tab with correct PF2e rules - Add Speed display to Status tab - Redesign AC/Speed to use Card components matching tab design - Simplify Rest button to match card-based design pattern Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ import {
|
||||
Star,
|
||||
Coins,
|
||||
Moon,
|
||||
Download,
|
||||
Eye,
|
||||
Footprints,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -38,6 +41,7 @@ import { ActionsTab } from './actions-tab';
|
||||
import { RestModal } from './rest-modal';
|
||||
import { AlchemyTab } from './alchemy-tab';
|
||||
import { useCharacterSocket } from '@/shared/hooks/use-character-socket';
|
||||
import { downloadCharacterHTML } from '../utils/export-character-html';
|
||||
import type { Character, CharacterItem, CharacterFeat, Campaign, RestResult, CharacterAlchemyState, CharacterFormula, CharacterPreparedItem } from '@/shared/types';
|
||||
|
||||
type TabType = 'status' | 'skills' | 'inventory' | 'feats' | 'spells' | 'alchemy' | 'actions';
|
||||
@@ -600,49 +604,54 @@ export function CharacterSheetPage() {
|
||||
|
||||
const acData = calculateAC();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* HP & AC Row - 75/25 split */}
|
||||
<div className="flex gap-3">
|
||||
{/* HP Control - 75% */}
|
||||
<div className="flex-[3]">
|
||||
<HpControl
|
||||
hpCurrent={character.hpCurrent}
|
||||
hpMax={character.hpMax}
|
||||
hpTemp={character.hpTemp}
|
||||
onHpChange={handleHpChange}
|
||||
/>
|
||||
</div>
|
||||
// Get speed from pathbuilder data
|
||||
const pbSpeed = character.pathbuilderData as {
|
||||
build?: { attributes?: { speed?: number } };
|
||||
} | undefined;
|
||||
const speed = pbSpeed?.build?.attributes?.speed || 25;
|
||||
|
||||
{/* AC Display - Shield Shape */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-1">
|
||||
<div className="relative">
|
||||
{/* Shield SVG */}
|
||||
<svg viewBox="0 0 80 96" className="w-16 h-20 sm:w-20 sm:h-24">
|
||||
<path
|
||||
d="M40 4 L76 16 L76 44 C76 68 56 88 40 94 C24 88 4 68 4 44 L4 16 Z"
|
||||
className={`stroke-2 ${acData.hasProficiency ? 'fill-blue-500/20 stroke-blue-500/50' : 'fill-red-500/20 stroke-red-500/50'}`}
|
||||
/>
|
||||
</svg>
|
||||
{/* AC Number centered on shield */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pt-1">
|
||||
<span className={`text-[10px] font-medium ${acData.hasProficiency ? 'text-blue-400/70' : 'text-red-400/70'}`}>RK</span>
|
||||
<span className={`text-2xl sm:text-3xl font-bold -mt-1 ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>{acData.total}</span>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* HP Row - Full width on mobile */}
|
||||
<HpControl
|
||||
hpCurrent={character.hpCurrent}
|
||||
hpMax={character.hpMax}
|
||||
hpTemp={character.hpTemp}
|
||||
onHpChange={handleHpChange}
|
||||
/>
|
||||
|
||||
{/* AC & Speed Row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* AC Card */}
|
||||
<Card>
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className={`h-4 w-4 ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`} />
|
||||
<span className={`text-xs font-medium ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>Rüstungsklasse</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<p className={`text-3xl font-bold ${acData.hasProficiency ? 'text-blue-400' : 'text-red-400'}`}>{acData.total}</p>
|
||||
{acData.shieldBonus > 0 && (
|
||||
<span className="text-xs font-semibold text-cyan-400 -mt-1">+{acData.shieldBonus}</span>
|
||||
<span className="text-sm font-semibold text-cyan-400">+{acData.shieldBonus}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Proficiency Info */}
|
||||
<div className="text-center">
|
||||
<p className={`text-[10px] font-medium ${acData.hasProficiency ? 'text-text-secondary' : 'text-red-400'}`}>
|
||||
{acData.profName}
|
||||
</p>
|
||||
<p className={`text-[10px] ${acData.hasProficiency ? 'text-text-muted' : 'text-red-400/70'}`}>
|
||||
<p className={`text-xs mt-1 ${acData.hasProficiency ? 'text-text-secondary' : 'text-red-400'}`}>
|
||||
{acData.profRank}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Speed Card */}
|
||||
<Card>
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Footprints className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">Geschwindigkeit</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-emerald-400">{speed}</p>
|
||||
<p className="text-xs text-text-secondary mt-1">ft / Aktion</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Abilities */}
|
||||
@@ -670,77 +679,134 @@ export function CharacterSheetPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Saving Throws */}
|
||||
{/* Saving Throws & Perception */}
|
||||
{(() => {
|
||||
const getAbilityMod = (abilityType: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA') => {
|
||||
const ability = character.abilities.find(a => a.ability === abilityType);
|
||||
return ability ? Math.floor((ability.score - 10) / 2) : 0;
|
||||
};
|
||||
|
||||
// TODO: Get actual proficiency from Pathbuilder data when available
|
||||
// For now, assume trained (+2) as baseline for all saves
|
||||
const baseProficiency = 2;
|
||||
const level = character.level;
|
||||
|
||||
// Get proficiency values from Pathbuilder data
|
||||
const pb = character.pathbuilderData as {
|
||||
build?: {
|
||||
proficiencies?: Record<string, number>;
|
||||
};
|
||||
} | undefined;
|
||||
const proficiencies = pb?.build?.proficiencies || {};
|
||||
|
||||
// Calculate save totals using actual proficiency data
|
||||
// Proficiency values: 0 = untrained, 2 = trained, 4 = expert, 6 = master, 8 = legendary
|
||||
const getSaveTotal = (saveName: string, abilityMod: number) => {
|
||||
const prof = proficiencies[saveName] || 2; // Default to trained if not found
|
||||
const levelBonus = prof > 0 ? level : 0;
|
||||
return abilityMod + levelBonus + prof;
|
||||
};
|
||||
|
||||
const getSaveProfName = (saveName: string) => {
|
||||
const prof = proficiencies[saveName] || 2;
|
||||
if (prof === 0) return 'Ungeübt';
|
||||
if (prof === 2) return 'Geübt';
|
||||
if (prof === 4) return 'Experte';
|
||||
if (prof === 6) return 'Meister';
|
||||
if (prof === 8) return 'Legendär';
|
||||
return 'Geübt';
|
||||
};
|
||||
|
||||
const saves = [
|
||||
{
|
||||
name: 'Zähigkeit',
|
||||
shortName: 'ZÄH',
|
||||
key: 'fortitude',
|
||||
ability: 'CON' as const,
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/20',
|
||||
},
|
||||
{
|
||||
name: 'Reflex',
|
||||
shortName: 'REF',
|
||||
key: 'reflex',
|
||||
ability: 'DEX' as const,
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/20',
|
||||
},
|
||||
{
|
||||
name: 'Willen',
|
||||
shortName: 'WIL',
|
||||
key: 'will',
|
||||
ability: 'WIS' as const,
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Rettungswürfe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saves.map((save) => {
|
||||
const abilityMod = getAbilityMod(save.ability);
|
||||
const total = abilityMod + level + baseProficiency;
|
||||
const bonusString = total >= 0 ? `+${total}` : `${total}`;
|
||||
// Calculate Perception
|
||||
// Perception = WIS mod + proficiency bonus + level (if trained+)
|
||||
const perceptionSkill = character.skills.find(s => s.skillName === 'Perception');
|
||||
const perceptionProf = perceptionSkill
|
||||
? PROFICIENCY_BONUS[perceptionSkill.proficiency] || 0
|
||||
: (proficiencies['perception'] || 2);
|
||||
const perceptionProfName = perceptionSkill
|
||||
? PROFICIENCY_NAMES[perceptionSkill.proficiency] || 'Geübt'
|
||||
: getSaveProfName('perception');
|
||||
const wisdomMod = getAbilityMod('WIS');
|
||||
const perceptionLevelBonus = perceptionProf > 0 ? level : 0;
|
||||
const perceptionTotal = wisdomMod + perceptionLevelBonus + perceptionProf;
|
||||
const perceptionString = perceptionTotal >= 0 ? `+${perceptionTotal}` : `${perceptionTotal}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={save.name}
|
||||
className={`text-center p-3 rounded-lg ${save.bgColor}`}
|
||||
>
|
||||
<p className={`text-xs font-medium mb-1 ${save.color}`}>
|
||||
{save.name}
|
||||
</p>
|
||||
<p className={`text-2xl font-bold ${save.color}`}>
|
||||
{bonusString}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{ABILITY_NAMES[save.ability].slice(0, 3).toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
{/* Perception */}
|
||||
<div className="sm:col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">Wahrnehmung</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-amber-400">{perceptionString}</p>
|
||||
<p className="text-xs text-text-secondary mt-1">{perceptionProfName}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Saving Throws */}
|
||||
<div className="sm:col-span-3">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Rettungswürfe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{saves.map((save) => {
|
||||
const abilityMod = getAbilityMod(save.ability);
|
||||
const total = getSaveTotal(save.key, abilityMod);
|
||||
const bonusString = total >= 0 ? `+${total}` : `${total}`;
|
||||
const profName = getSaveProfName(save.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={save.name}
|
||||
className={`text-center p-3 rounded-lg ${save.bgColor}`}
|
||||
>
|
||||
<p className={`text-xs font-medium mb-1 ${save.color}`}>
|
||||
{save.name}
|
||||
</p>
|
||||
<p className={`text-2xl font-bold ${save.color}`}>
|
||||
{bonusString}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{profName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -779,14 +845,17 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
{/* Rest Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 border-indigo-500/30 text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300"
|
||||
onClick={() => setShowRestModal(true)}
|
||||
>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
Rasten (8 Stunden)
|
||||
</Button>
|
||||
<Card className="hover:border-indigo-500/50 transition-colors cursor-pointer" onClick={() => setShowRestModal(true)}>
|
||||
<CardContent className="p-4 flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
||||
<Moon className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-indigo-400">Rasten</p>
|
||||
<p className="text-xs text-text-secondary">8 Stunden Ruhe + Tägliche Vorbereitung</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1502,16 +1571,26 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditCharacter(true)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadCharacterHTML(character)}
|
||||
title="Als HTML exportieren"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditCharacter(true)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
|
||||
867
client/src/features/characters/utils/export-character-html.ts
Normal file
867
client/src/features/characters/utils/export-character-html.ts
Normal file
@@ -0,0 +1,867 @@
|
||||
import type { Character, Proficiency } from '@/shared/types';
|
||||
|
||||
// Pathbuilder data structure
|
||||
interface PathbuilderData {
|
||||
build?: {
|
||||
name?: string;
|
||||
class?: string;
|
||||
ancestry?: string;
|
||||
heritage?: string;
|
||||
background?: string;
|
||||
alignment?: string;
|
||||
gender?: string;
|
||||
age?: string;
|
||||
deity?: string;
|
||||
sizeName?: string;
|
||||
languages?: string[];
|
||||
attributes?: {
|
||||
speed?: number;
|
||||
classDC?: number;
|
||||
};
|
||||
acTotal?: {
|
||||
acTotal?: number;
|
||||
};
|
||||
proficiencies?: Record<string, number>;
|
||||
perception?: number;
|
||||
};
|
||||
translations?: {
|
||||
class?: { germanName?: string };
|
||||
ancestry?: { germanName?: string };
|
||||
heritage?: { germanName?: string };
|
||||
background?: { germanName?: string };
|
||||
};
|
||||
}
|
||||
|
||||
// Skill data with German names and linked abilities
|
||||
const SKILL_DATA: Record<string, { german: string; ability: 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA' }> = {
|
||||
'Acrobatics': { german: 'Akrobatik', ability: 'DEX' },
|
||||
'Arcana': { german: 'Arkane Künste', ability: 'INT' },
|
||||
'Athletics': { german: 'Athletik', ability: 'STR' },
|
||||
'Crafting': { german: 'Handwerkskunst', ability: 'INT' },
|
||||
'Deception': { german: 'Täuschung', ability: 'CHA' },
|
||||
'Diplomacy': { german: 'Diplomatie', ability: 'CHA' },
|
||||
'Intimidation': { german: 'Einschüchtern', ability: 'CHA' },
|
||||
'Medicine': { german: 'Medizin', ability: 'WIS' },
|
||||
'Nature': { german: 'Naturkunde', ability: 'WIS' },
|
||||
'Occultism': { german: 'Okkultismus', ability: 'INT' },
|
||||
'Performance': { german: 'Darbietung', ability: 'CHA' },
|
||||
'Religion': { german: 'Religionskunde', ability: 'WIS' },
|
||||
'Society': { german: 'Gesellschaftskunde', ability: 'INT' },
|
||||
'Stealth': { german: 'Heimlichkeit', ability: 'DEX' },
|
||||
'Survival': { german: 'Überleben', ability: 'WIS' },
|
||||
'Thievery': { german: 'Diebeskunst', ability: 'DEX' },
|
||||
};
|
||||
|
||||
// Proficiency bonus values
|
||||
const PROFICIENCY_BONUS: Record<Proficiency, number> = {
|
||||
'UNTRAINED': 0,
|
||||
'TRAINED': 2,
|
||||
'EXPERT': 4,
|
||||
'MASTER': 6,
|
||||
'LEGENDARY': 8,
|
||||
};
|
||||
|
||||
// Helper to format modifier with +/- sign
|
||||
function formatMod(value: number): string {
|
||||
return value >= 0 ? `+${value}` : `${value}`;
|
||||
}
|
||||
|
||||
// Helper to get ability modifier
|
||||
function getAbilityMod(score: number): number {
|
||||
return Math.floor((score - 10) / 2);
|
||||
}
|
||||
|
||||
// Helper to format bulk
|
||||
function formatBulk(bulk: string | undefined): string {
|
||||
if (!bulk) return '-';
|
||||
if (bulk === 'L') return 'L';
|
||||
return bulk;
|
||||
}
|
||||
|
||||
// Get proficiency label
|
||||
function getProficiencyLabel(prof: Proficiency): string {
|
||||
const labels: Record<Proficiency, string> = {
|
||||
'UNTRAINED': 'Ungeübt',
|
||||
'TRAINED': 'Geübt',
|
||||
'EXPERT': 'Experte',
|
||||
'MASTER': 'Meister',
|
||||
'LEGENDARY': 'Legendär',
|
||||
};
|
||||
return labels[prof] || 'Ungeübt';
|
||||
}
|
||||
|
||||
// Group feats by source
|
||||
function groupFeatsBySource(feats: Character['feats']) {
|
||||
const groups: Record<string, typeof feats> = {};
|
||||
for (const feat of feats) {
|
||||
const source = feat.source || 'BONUS';
|
||||
if (!groups[source]) groups[source] = [];
|
||||
groups[source].push(feat);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Translate feat source
|
||||
function translateFeatSource(source: string): string {
|
||||
const translations: Record<string, string> = {
|
||||
'CLASS': 'Klasse',
|
||||
'ANCESTRY': 'Abstammung',
|
||||
'GENERAL': 'Allgemein',
|
||||
'SKILL': 'Fertigkeit',
|
||||
'BONUS': 'Bonus',
|
||||
'ARCHETYPE': 'Archetyp',
|
||||
};
|
||||
return translations[source] || source;
|
||||
}
|
||||
|
||||
export function generateCharacterHTML(character: Character): string {
|
||||
const abilities = character.abilities || [];
|
||||
const getAbility = (name: string) => abilities.find(a => a.ability === name)?.score || 10;
|
||||
|
||||
const STR = getAbility('STR');
|
||||
const DEX = getAbility('DEX');
|
||||
const CON = getAbility('CON');
|
||||
const INT = getAbility('INT');
|
||||
const WIS = getAbility('WIS');
|
||||
const CHA = getAbility('CHA');
|
||||
|
||||
// Get pathbuilder data and translations
|
||||
const pb = character.pathbuilderData as PathbuilderData | undefined;
|
||||
const translations = pb?.translations;
|
||||
const build = pb?.build;
|
||||
|
||||
// Get translated or original names
|
||||
const ancestry = translations?.ancestry?.germanName || build?.ancestry || character.ancestryId || '-';
|
||||
const heritage = translations?.heritage?.germanName || build?.heritage || character.heritageId || '-';
|
||||
const background = translations?.background?.germanName || build?.background || character.backgroundId || '-';
|
||||
const charClass = translations?.class?.germanName || build?.class || character.classId || '-';
|
||||
const alignment = build?.alignment || '-';
|
||||
|
||||
// Get AC from pathbuilder
|
||||
const ac = build?.acTotal?.acTotal || 10;
|
||||
|
||||
// Get speed from pathbuilder
|
||||
const speed = build?.attributes?.speed || 25;
|
||||
|
||||
// Calculate Perception correctly
|
||||
// Perception = WIS mod + proficiency bonus + level (if trained+)
|
||||
const proficiencies = build?.proficiencies || {};
|
||||
const perceptionProf = proficiencies['perception'] || 2; // Default to trained
|
||||
const perceptionLevelBonus = perceptionProf > 0 ? character.level : 0;
|
||||
const perception = getAbilityMod(WIS) + perceptionLevelBonus + perceptionProf;
|
||||
const perceptionProfName = perceptionProf === 0 ? 'Ungeübt' :
|
||||
perceptionProf === 2 ? 'Geübt' :
|
||||
perceptionProf === 4 ? 'Experte' :
|
||||
perceptionProf === 6 ? 'Meister' :
|
||||
perceptionProf === 8 ? 'Legendär' : 'Geübt';
|
||||
|
||||
// Check if tabs have content
|
||||
const hasSpells = character.spells && character.spells.length > 0;
|
||||
const hasAlchemy = character.alchemyState && (character.formulas?.length || 0) > 0;
|
||||
const hasPreparedItems = character.preparedItems && character.preparedItems.length > 0;
|
||||
|
||||
// Group items by equipped status
|
||||
const equippedItems = character.items?.filter(i => i.isEquipped) || [];
|
||||
const carriedItems = character.items?.filter(i => !i.isEquipped) || [];
|
||||
|
||||
// Calculate total bulk
|
||||
const totalBulk = character.items?.reduce((sum, item) => {
|
||||
const bulk = item.equipment?.bulk;
|
||||
if (!bulk) return sum;
|
||||
if (bulk === 'L') return sum + 0.1;
|
||||
const num = parseFloat(bulk);
|
||||
return isNaN(num) ? sum : sum + num * item.quantity;
|
||||
}, 0) || 0;
|
||||
|
||||
// Filter skills (exclude saves and perception)
|
||||
const EXCLUDED_SKILLS = ['Fortitude', 'Reflex', 'Will', 'Perception'];
|
||||
const filteredSkills = (character.skills || []).filter(
|
||||
(skill) => !EXCLUDED_SKILLS.includes(skill.skillName)
|
||||
);
|
||||
|
||||
// Calculate skill modifiers
|
||||
const skillsWithMods = filteredSkills.map(skill => {
|
||||
const skillData = SKILL_DATA[skill.skillName];
|
||||
const germanName = skillData?.german || skill.skillName;
|
||||
const linkedAbility = skillData?.ability || 'INT';
|
||||
|
||||
const abilityScore = getAbility(linkedAbility);
|
||||
const abilityMod = getAbilityMod(abilityScore);
|
||||
const profBonus = PROFICIENCY_BONUS[skill.proficiency] || 0;
|
||||
const levelBonus = skill.proficiency !== 'UNTRAINED' ? character.level : 0;
|
||||
const totalBonus = abilityMod + levelBonus + profBonus;
|
||||
|
||||
const isLore = skill.skillName.toLowerCase().includes('lore');
|
||||
const displayName = isLore ? skill.skillName.replace(' Lore', '') + ' (Wissen)' : germanName;
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
modifier: totalBonus,
|
||||
proficiency: skill.proficiency,
|
||||
};
|
||||
});
|
||||
|
||||
// Get save values from pathbuilder proficiencies
|
||||
const getSaveTotal = (saveName: string, abilityMod: number) => {
|
||||
const prof = proficiencies[saveName] || 2; // Default to trained
|
||||
const levelBonus = prof > 0 ? character.level : 0;
|
||||
return abilityMod + levelBonus + prof;
|
||||
};
|
||||
|
||||
const getSaveProfName = (saveName: string) => {
|
||||
const prof = proficiencies[saveName] || 2;
|
||||
if (prof === 0) return 'Ungeübt';
|
||||
if (prof === 2) return 'Geübt';
|
||||
if (prof === 4) return 'Experte';
|
||||
if (prof === 6) return 'Meister';
|
||||
if (prof === 8) return 'Legendär';
|
||||
return 'Geübt';
|
||||
};
|
||||
|
||||
const saveReflex = getSaveTotal('reflex', getAbilityMod(DEX));
|
||||
const saveFortitude = getSaveTotal('fortitude', getAbilityMod(CON));
|
||||
const saveWill = getSaveTotal('will', getAbilityMod(WIS));
|
||||
|
||||
const reflexProfName = getSaveProfName('reflex');
|
||||
const fortitudeProfName = getSaveProfName('fortitude');
|
||||
const willProfName = getSaveProfName('will');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${character.name} - Charakterbogen</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.4;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24pt;
|
||||
border-bottom: 3px solid #7c3aed;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14pt;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
margin: 16px 0 8px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 12pt;
|
||||
color: #7c3aed;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 4px;
|
||||
margin: 12px 0 8px 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #f8f5ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9d5ff;
|
||||
}
|
||||
|
||||
.header-info div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-info .label {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-info .value {
|
||||
font-weight: 600;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.abilities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ability-box {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: #f8f5ff;
|
||||
border: 2px solid #7c3aed;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ability-box .name {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.ability-box .mod {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ability-box .score {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-block {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-block .title {
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-block .big-value {
|
||||
font-size: 24pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-block .detail {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.saves-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.save-box {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: #f0fdf4;
|
||||
border: 2px solid #22c55e;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.save-box .name {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.save-box .value {
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.save-box .prof {
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skill-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skill-row:nth-child(odd) {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.skill-row .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.skill-row .value {
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.skill-row .prof {
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.feats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feat-item {
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.feat-item .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feat-item .level {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #ddd;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.items-table tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.spells-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spell-level-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.spell-level-title {
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #f8f5ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.spells-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.spell-item {
|
||||
padding: 4px 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alchemy-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.formula-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.formula-item {
|
||||
padding: 6px 8px;
|
||||
background: #fdf4ff;
|
||||
border: 1px solid #e9d5ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.formula-item .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.formula-item .level {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.conditions-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.condition-badge {
|
||||
padding: 4px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 16px;
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notes-box {
|
||||
padding: 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${character.name}</h1>
|
||||
|
||||
<div class="header-info">
|
||||
<div>
|
||||
<span class="label">Abstammung</span>
|
||||
<span class="value">${ancestry}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Erbe</span>
|
||||
<span class="value">${heritage}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Hintergrund</span>
|
||||
<span class="value">${background}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Klasse</span>
|
||||
<span class="value">${charClass}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Stufe</span>
|
||||
<span class="value">${character.level}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Gesinnung</span>
|
||||
<span class="value">${alignment}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Attribute</h2>
|
||||
<div class="abilities">
|
||||
<div class="ability-box">
|
||||
<div class="name">STR</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(STR))}</div>
|
||||
<div class="score">${STR}</div>
|
||||
</div>
|
||||
<div class="ability-box">
|
||||
<div class="name">DEX</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(DEX))}</div>
|
||||
<div class="score">${DEX}</div>
|
||||
</div>
|
||||
<div class="ability-box">
|
||||
<div class="name">CON</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(CON))}</div>
|
||||
<div class="score">${CON}</div>
|
||||
</div>
|
||||
<div class="ability-box">
|
||||
<div class="name">INT</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(INT))}</div>
|
||||
<div class="score">${INT}</div>
|
||||
</div>
|
||||
<div class="ability-box">
|
||||
<div class="name">WIS</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(WIS))}</div>
|
||||
<div class="score">${WIS}</div>
|
||||
</div>
|
||||
<div class="ability-box">
|
||||
<div class="name">CHA</div>
|
||||
<div class="mod">${formatMod(getAbilityMod(CHA))}</div>
|
||||
<div class="score">${CHA}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-block">
|
||||
<div class="title">Trefferpunkte</div>
|
||||
<div class="big-value">${character.hpCurrent} / ${character.hpMax}</div>
|
||||
${character.hpTemp ? `<div class="detail">Temporär: ${character.hpTemp}</div>` : ''}
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<div class="title">Rüstungsklasse</div>
|
||||
<div class="big-value">${ac}</div>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<div class="title">Bewegung</div>
|
||||
<div class="big-value">${speed} ft</div>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<div class="title">Wahrnehmung</div>
|
||||
<div class="big-value">${formatMod(perception)}</div>
|
||||
<div class="detail">${perceptionProfName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Rettungswürfe</h2>
|
||||
<div class="saves-grid">
|
||||
<div class="save-box">
|
||||
<div class="name">Zähigkeit</div>
|
||||
<div class="value">${formatMod(saveFortitude)}</div>
|
||||
<div class="prof">${fortitudeProfName}</div>
|
||||
</div>
|
||||
<div class="save-box">
|
||||
<div class="name">Reflex</div>
|
||||
<div class="value">${formatMod(saveReflex)}</div>
|
||||
<div class="prof">${reflexProfName}</div>
|
||||
</div>
|
||||
<div class="save-box">
|
||||
<div class="name">Wille</div>
|
||||
<div class="value">${formatMod(saveWill)}</div>
|
||||
<div class="prof">${willProfName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${character.conditions && character.conditions.length > 0 ? `
|
||||
<h2>Zustände</h2>
|
||||
<div class="conditions-list">
|
||||
${character.conditions.map(c => `
|
||||
<span class="condition-badge">${c.nameGerman || c.name}${c.value ? ` ${c.value}` : ''}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<h2>Fertigkeiten</h2>
|
||||
<div class="skills-list">
|
||||
${skillsWithMods.map(skill => `
|
||||
<div class="skill-row">
|
||||
<span class="name">${skill.name}</span>
|
||||
<span>
|
||||
<span class="value">${formatMod(skill.modifier)}</span>
|
||||
<span class="prof">(${getProficiencyLabel(skill.proficiency)})</span>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<h2>Talente</h2>
|
||||
${Object.entries(groupFeatsBySource(character.feats || [])).map(([source, feats]) => `
|
||||
<h3>${translateFeatSource(source)}</h3>
|
||||
<div class="feats-grid">
|
||||
${feats.map(feat => `
|
||||
<div class="feat-item">
|
||||
<span class="name">${feat.nameGerman || feat.name}</span>
|
||||
<span class="level">Stufe ${feat.level || 1}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
|
||||
<div class="page-break"></div>
|
||||
|
||||
<h2>Ausrüstung</h2>
|
||||
<p style="margin-bottom: 8px; color: #666;">Traglast: ${totalBulk.toFixed(1)} Bulk</p>
|
||||
|
||||
${equippedItems.length > 0 ? `
|
||||
<h3>Angelegt</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gegenstand</th>
|
||||
<th>Menge</th>
|
||||
<th>Bulk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${equippedItems.map(item => `
|
||||
<tr>
|
||||
<td>${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${formatBulk(item.equipment?.bulk)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : ''}
|
||||
|
||||
${carriedItems.length > 0 ? `
|
||||
<h3>Mitgeführt</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gegenstand</th>
|
||||
<th>Menge</th>
|
||||
<th>Bulk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${carriedItems.map(item => `
|
||||
<tr>
|
||||
<td>${item.equipment?.nameGerman || item.equipment?.name || item.customName || '-'}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${formatBulk(item.equipment?.bulk)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : ''}
|
||||
|
||||
${hasSpells ? `
|
||||
<h2>Zauber</h2>
|
||||
<div class="spells-section">
|
||||
${Object.entries(
|
||||
(character.spells || []).reduce((acc, spell) => {
|
||||
const level = spell.spellLevel || 0;
|
||||
if (!acc[level]) acc[level] = [];
|
||||
acc[level].push(spell);
|
||||
return acc;
|
||||
}, {} as Record<number, typeof character.spells>)
|
||||
).sort(([a], [b]) => Number(a) - Number(b)).map(([level, spells]) => `
|
||||
<div class="spell-level-group">
|
||||
<div class="spell-level-title">${level === '0' ? 'Zaubertricks' : `Grad ${level}`}</div>
|
||||
<div class="spells-list">
|
||||
${spells!.map(spell => `
|
||||
<div class="spell-item">${spell.nameGerman || spell.name}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${hasAlchemy || hasPreparedItems ? `
|
||||
<h2>Alchemie</h2>
|
||||
<div class="alchemy-section">
|
||||
${character.alchemyState ? `
|
||||
<div class="stats-grid" style="margin-bottom: 12px;">
|
||||
<div class="stat-block">
|
||||
<div class="title">Vielseitige Phiolen</div>
|
||||
<div class="big-value">${character.alchemyState.versatileVialsCurrent} / ${character.alchemyState.versatileVialsMax}</div>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<div class="title">Fortgeschrittene Alchemie</div>
|
||||
<div class="big-value">${character.alchemyState.advancedAlchemyBatch} / ${character.alchemyState.advancedAlchemyMax}</div>
|
||||
<div class="detail">Chargen verwendet</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${(character.formulas?.length || 0) > 0 ? `
|
||||
<h3>Formelbuch</h3>
|
||||
<div class="formula-grid">
|
||||
${character.formulas!.map(formula => `
|
||||
<div class="formula-item">
|
||||
<span class="name">${formula.equipment?.nameGerman || formula.nameGerman || formula.name}</span>
|
||||
${formula.equipment?.level !== undefined ? `<span class="level"> (Stufe ${formula.equipment.level})</span>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${hasPreparedItems ? `
|
||||
<h3>Vorbereitete Gegenstände</h3>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gegenstand</th>
|
||||
<th>Menge</th>
|
||||
<th>Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${character.preparedItems!.map(item => `
|
||||
<tr>
|
||||
<td>${item.equipment?.nameGerman || item.nameGerman || item.name}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${item.isInfused ? 'Infundiert' : 'Hergestellt'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="footer">
|
||||
Exportiert am ${new Date().toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} • Dimension 47
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function downloadCharacterHTML(character: Character): void {
|
||||
const html = generateCharacterHTML(character);
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${character.name.replace(/[^a-zA-Z0-9äöüÄÖÜß\s-]/g, '')}_Charakterbogen.html`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user