add class management with spell unlocks

This commit is contained in:
Sky Johnson 2025-08-29 08:43:18 -05:00
parent 59724dee81
commit 82186a5951
12 changed files with 1118 additions and 11 deletions

View File

@ -0,0 +1,141 @@
// All available spells data (loaded from API)
let allSpells = [];
const searchInput = document.getElementById('spell-search');
const resultsDiv = document.getElementById('spell-results');
const selectedDiv = document.getElementById('selected-spell');
const spellIdInput = document.getElementById('spell-id');
let currentResults = [];
// Load spells data from API
async function loadSpells() {
try {
const response = await fetch('/admin/api/spells');
if (response.ok) {
allSpells = await response.json();
} else {
console.error('Failed to load spells');
}
} catch (error) {
console.error('Error loading spells:', error);
}
}
// Initialize data when DOM is loaded
document.addEventListener('DOMContentLoaded', loadSpells);
// Simple fuzzy search function
function fuzzyMatch(search, target) {
search = search.toLowerCase();
target = target.toLowerCase();
// Exact match gets highest score
if (target.includes(search)) {
return 1000 - target.indexOf(search);
}
// Character-by-character fuzzy matching
let score = 0;
let searchIndex = 0;
for (let i = 0; i < target.length && searchIndex < search.length; i++) {
if (target[i] === search[searchIndex]) {
score += 10;
searchIndex++;
}
}
// Only return score if we matched all search characters
return searchIndex === search.length ? score : 0;
}
function getSpellTypeName(type) {
switch (type) {
case 0:
return 'Heal';
case 1:
return 'Damage';
case 2:
return 'Sleep';
case 3:
return 'Uber Attack';
case 4:
return 'Uber Defense';
default:
return 'Unknown';
}
}
function searchSpells() {
const query = searchInput.value.trim();
if (query.length < 2) {
resultsDiv.style.display = 'none';
return;
}
// Search and score results
const scored = allSpells.map(spell => ({
...spell,
score: fuzzyMatch(query, spell.Name)
})).filter(spell => spell.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 8); // Limit to 8 results
currentResults = scored;
if (scored.length === 0) {
resultsDiv.innerHTML = '<div style="padding: 0.5rem; color: #666;">No spells found</div>';
} else {
resultsDiv.innerHTML = scored.map((spell, index) =>
`<div class="spell-result" data-index="${index}" style="padding: 0.5rem; cursor: pointer; border-bottom: 1px solid #eee;">
<strong>${spell.Name}</strong> - ${getSpellTypeName(spell.Type)}
<span style="color: #666;">(${spell.MP} MP, ${spell.Power} Power)</span>
</div>`
).join('');
}
resultsDiv.style.display = 'block';
}
function selectSpell(spell) {
selectedDiv.innerHTML = `
<strong>${spell.Name}</strong> - ${getSpellTypeName(spell.Type)}<br>
<small>MP Cost: ${spell.MP} | Power: ${spell.Power}</small>
${spell.Lore ? `<br><em>${spell.Lore}</em>` : ''}
`;
spellIdInput.value = spell.ID;
searchInput.value = spell.Name;
resultsDiv.style.display = 'none';
}
// Event listeners
searchInput.addEventListener('input', searchSpells);
searchInput.addEventListener('focus', () => {
if (currentResults.length > 0) {
resultsDiv.style.display = 'block';
}
});
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
resultsDiv.style.display = 'none';
}
});
resultsDiv.addEventListener('click', (e) => {
const resultEl = e.target.closest('.spell-result');
if (resultEl) {
const index = parseInt(resultEl.dataset.index);
selectSpell(currentResults[index]);
}
});
// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
resultsDiv.style.display = 'none';
}
});

Binary file not shown.

View File

@ -91,3 +91,12 @@ func All() ([]*Class, error) {
err := database.Select(&classes, "SELECT * FROM classes ORDER BY id DESC")
return classes, err
}
func ByName(name string) (*Class, error) {
var class Class
err := database.Get(&class, "SELECT * FROM classes WHERE name = %s COLLATE NOCASE", name)
if err != nil {
return nil, fmt.Errorf("class with name '%s' not found", name)
}
return &class, nil
}

View File

@ -126,6 +126,15 @@ func ByValueRange(minValue, maxValue int) ([]*Item, error) {
return items, err
}
func ByName(name string) (*Item, error) {
var item Item
err := database.Get(&item, "SELECT * FROM items WHERE name = %s COLLATE NOCASE", name)
if err != nil {
return nil, fmt.Errorf("item with name '%s' not found", name)
}
return &item, nil
}
// Helper methods
func (i *Item) IsWeapon() bool {
return i.Type == TypeWeapon

View File

@ -4,11 +4,14 @@ import (
"dk/internal/components"
"dk/internal/database"
"dk/internal/helpers"
"dk/internal/models/classes"
"dk/internal/models/items"
"dk/internal/models/monsters"
"dk/internal/models/news"
"dk/internal/models/spells"
"dk/internal/models/towns"
"dk/internal/models/users"
"encoding/json"
"fmt"
"runtime"
"strconv"
@ -52,6 +55,19 @@ func RegisterAdminRoutes(app *sushi.App) {
group.Post("/monsters/new", adminMonsterCreate)
group.Get("/monsters/:id", adminMonsterEdit)
group.Post("/monsters/:id", adminMonsterUpdate)
group.Get("/items", adminItemsIndex)
group.Get("/items/new", adminItemNew)
group.Post("/items/new", adminItemCreate)
group.Get("/items/:id", adminItemEdit)
group.Post("/items/:id", adminItemUpdate)
group.Get("/classes", adminClassesIndex)
group.Get("/classes/new", adminClassNew)
group.Post("/classes/new", adminClassCreate)
group.Get("/classes/:id", adminClassEdit)
group.Post("/classes/:id", adminClassUpdate)
group.Get("/classes/:id/spells", adminClassSpells)
group.Post("/classes/:id/spells", adminClassSpellsUpdate)
group.Get("/api/spells", adminSpellsAPI)
}
func adminIndex(ctx sushi.Ctx) {
@ -250,7 +266,7 @@ func adminTownsIndex(ctx sushi.Ctx) {
var townList []*TownData
err := database.Select(&townList, `
SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list,
CASE
CASE
WHEN shop_list = '' THEN 0
ELSE (LENGTH(shop_list) - LENGTH(REPLACE(shop_list, ',', '')) + 1)
END as shop_item_count
@ -414,11 +430,11 @@ func checkTownCoordinateConflict(x, y, excludeID int) error {
if err != nil {
return nil // No conflict if no town found or database error
}
if existingTown != nil && existingTown.ID != excludeID {
return fmt.Errorf("a town already exists at coordinates (%d, %d): %s", x, y, existingTown.Name)
}
return nil
}
@ -608,11 +624,11 @@ func checkSpellNameConflict(name string, excludeID int) error {
if err != nil {
return nil // No conflict if no spell found or database error
}
if existingSpell != nil && existingSpell.ID != excludeID {
return fmt.Errorf("a spell with the name '%s' already exists", name)
}
return nil
}
@ -806,14 +822,568 @@ func checkMonsterNameConflict(name string, excludeID int) error {
if err != nil {
return nil // No conflict if no monster found or database error
}
if existingMonster != nil && existingMonster.ID != excludeID {
return fmt.Errorf("a monster with the name '%s' already exists", name)
}
return nil
}
func adminItemsIndex(ctx sushi.Ctx) {
pagination := helpers.Pagination{
Page: max(int(ctx.QueryArgs().GetUintOrZero("page")), 1),
PerPage: 30,
}
type ItemData struct {
ID int
Name string
Type int
TypeName string
Value int
Attack int
Defense int
Strength int
Dexterity int
MaxHP int
MaxMP int
ExpBonus int
GoldBonus int
Special string
Lore string
}
var itemList []*ItemData
err := database.Select(&itemList, `
SELECT id, name, type, value, attack, defense, strength, dexterity,
max_hp, max_mp, exp_bonus, gold_bonus, special, lore,
CASE type
WHEN 1 THEN 'Weapon'
WHEN 2 THEN 'Armor'
WHEN 3 THEN 'Shield'
WHEN 4 THEN 'Accessory'
ELSE 'Unknown'
END as type_name
FROM items
ORDER BY type ASC, value ASC, id ASC
LIMIT %d OFFSET %d`, pagination.PerPage, pagination.Offset())
if err != nil {
fmt.Printf("Error getting item list for admin index: %s", err.Error())
itemList = make([]*ItemData, 0)
}
type CountResult struct{ Count int }
var result CountResult
database.Get(&result, "SELECT COUNT(*) as count FROM items")
pagination.Total = result.Count
components.RenderAdminPage(ctx, "Item Management", "admin/items/index.html", map[string]any{
"items": itemList,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
})
}
func adminItemNew(ctx sushi.Ctx) {
item := items.New()
components.RenderAdminPage(ctx, "Add New Item", "admin/items/edit.html", map[string]any{
"item": item,
})
}
func adminItemCreate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
item := items.New()
if err := populateItemFromForm(ctx, item); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/items/new")
return
}
if err := item.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/items/new")
return
}
if err := checkItemNameConflict(item.Name, 0); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/items/new")
return
}
if err := item.Insert(); err != nil {
sess.SetFlash("error", "Failed to create item")
ctx.Redirect("/admin/items/new")
return
}
sess.SetFlash("success", fmt.Sprintf("Item %s created successfully", item.Name))
ctx.Redirect("/admin/items")
}
func adminItemEdit(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
item, err := items.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Item %d not found", id))
ctx.Redirect("/admin/items")
return
}
components.RenderAdminPage(ctx, fmt.Sprintf("Edit Item: %s", item.Name), "admin/items/edit.html", map[string]any{
"item": item,
})
}
func adminItemUpdate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
item, err := items.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Item %d not found", id))
ctx.Redirect("/admin/items")
return
}
// Check if delete was requested
if ctx.Form("delete").String() == "1" {
if err := item.Delete(); err != nil {
sess.SetFlash("error", "Failed to delete item")
ctx.Redirect(fmt.Sprintf("/admin/items/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Item %s deleted successfully", item.Name))
ctx.Redirect("/admin/items")
return
}
if err := populateItemFromForm(ctx, item); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/items/%d", id))
return
}
if err := item.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/items/%d", id))
return
}
if err := checkItemNameConflict(item.Name, item.ID); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/items/%d", id))
return
}
fields := map[string]any{
"name": item.Name,
"type": item.Type,
"value": item.Value,
"lore": item.Lore,
"attack": item.Attack,
"defense": item.Defense,
"strength": item.Strength,
"dexterity": item.Dexterity,
"max_hp": item.MaxHP,
"max_mp": item.MaxMP,
"exp_bonus": item.ExpBonus,
"gold_bonus": item.GoldBonus,
"special": item.Special,
}
if err := database.Update("items", fields, "id", id); err != nil {
sess.SetFlash("error", "Failed to update item")
ctx.Redirect(fmt.Sprintf("/admin/items/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Item %s updated successfully", item.Name))
ctx.Redirect("/admin/items")
}
func populateItemFromForm(ctx sushi.Ctx, item *items.Item) error {
item.Name = strings.TrimSpace(ctx.Form("name").String())
item.Type = ctx.Form("type").Int()
item.Value = ctx.Form("value").Int()
item.Lore = strings.TrimSpace(ctx.Form("lore").String())
item.Attack = ctx.Form("attack").Int()
item.Defense = ctx.Form("defense").Int()
item.Strength = ctx.Form("strength").Int()
item.Dexterity = ctx.Form("dexterity").Int()
item.MaxHP = ctx.Form("max_hp").Int()
item.MaxMP = ctx.Form("max_mp").Int()
item.ExpBonus = ctx.Form("exp_bonus").Int()
item.GoldBonus = ctx.Form("gold_bonus").Int()
item.Special = strings.TrimSpace(ctx.Form("special").String())
if item.Name == "" {
return fmt.Errorf("item name is required")
}
return nil
}
func checkItemNameConflict(name string, excludeID int) error {
existingItem, err := items.ByName(name)
if err != nil {
return nil // No conflict if no item found or database error
}
if existingItem != nil && existingItem.ID != excludeID {
return fmt.Errorf("an item with the name '%s' already exists", name)
}
return nil
}
func adminClassesIndex(ctx sushi.Ctx) {
pagination := helpers.Pagination{
Page: max(int(ctx.QueryArgs().GetUintOrZero("page")), 1),
PerPage: 30,
}
type ClassData struct {
ID int
Name string
Lore string
BaseHP int
BaseMP int
BaseSTR int
BaseDEX int
RateHP int
RateMP int
RateSTR int
RateDEX int
}
var classList []*ClassData
err := database.Select(&classList, `
SELECT id, name, lore, base_hp, base_mp, base_str, base_dex,
rate_hp, rate_mp, rate_str, rate_dex
FROM classes
ORDER BY id DESC
LIMIT %d OFFSET %d`, pagination.PerPage, pagination.Offset())
if err != nil {
fmt.Printf("Error getting class list for admin index: %s", err.Error())
classList = make([]*ClassData, 0)
}
type CountResult struct{ Count int }
var result CountResult
database.Get(&result, "SELECT COUNT(*) as count FROM classes")
pagination.Total = result.Count
components.RenderAdminPage(ctx, "Class Management", "admin/classes/index.html", map[string]any{
"classes": classList,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
})
}
func adminClassNew(ctx sushi.Ctx) {
class := classes.New()
components.RenderAdminPage(ctx, "Add New Class", "admin/classes/edit.html", map[string]any{
"class": class,
})
}
func adminClassCreate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
class := classes.New()
if err := populateClassFromForm(ctx, class); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/classes/new")
return
}
if err := class.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/classes/new")
return
}
if err := checkClassNameConflict(class.Name, 0); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/classes/new")
return
}
if err := class.Insert(); err != nil {
sess.SetFlash("error", "Failed to create class")
ctx.Redirect("/admin/classes/new")
return
}
sess.SetFlash("success", fmt.Sprintf("Class %s created successfully", class.Name))
ctx.Redirect("/admin/classes")
}
func adminClassEdit(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
class, err := classes.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Class %d not found", id))
ctx.Redirect("/admin/classes")
return
}
components.RenderAdminPage(ctx, fmt.Sprintf("Edit Class: %s", class.Name), "admin/classes/edit.html", map[string]any{
"class": class,
})
}
func adminClassUpdate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
class, err := classes.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Class %d not found", id))
ctx.Redirect("/admin/classes")
return
}
// Check if delete was requested
if ctx.Form("delete").String() == "1" {
if err := class.Delete(); err != nil {
sess.SetFlash("error", "Failed to delete class")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Class %s deleted successfully", class.Name))
ctx.Redirect("/admin/classes")
return
}
if err := populateClassFromForm(ctx, class); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/classes/%d", id))
return
}
if err := class.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/classes/%d", id))
return
}
if err := checkClassNameConflict(class.Name, class.ID); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/classes/%d", id))
return
}
fields := map[string]any{
"name": class.Name,
"lore": class.Lore,
"base_hp": class.BaseHP,
"base_mp": class.BaseMP,
"base_str": class.BaseSTR,
"base_dex": class.BaseDEX,
"rate_hp": class.RateHP,
"rate_mp": class.RateMP,
"rate_str": class.RateSTR,
"rate_dex": class.RateDEX,
}
if err := database.Update("classes", fields, "id", id); err != nil {
sess.SetFlash("error", "Failed to update class")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Class %s updated successfully", class.Name))
ctx.Redirect("/admin/classes")
}
func populateClassFromForm(ctx sushi.Ctx, class *classes.Class) error {
class.Name = strings.TrimSpace(ctx.Form("name").String())
class.Lore = strings.TrimSpace(ctx.Form("lore").String())
class.BaseHP = ctx.Form("base_hp").Int()
class.BaseMP = ctx.Form("base_mp").Int()
class.BaseSTR = ctx.Form("base_str").Int()
class.BaseDEX = ctx.Form("base_dex").Int()
class.RateHP = ctx.Form("rate_hp").Int()
class.RateMP = ctx.Form("rate_mp").Int()
class.RateSTR = ctx.Form("rate_str").Int()
class.RateDEX = ctx.Form("rate_dex").Int()
if class.Name == "" {
return fmt.Errorf("class name is required")
}
return nil
}
func checkClassNameConflict(name string, excludeID int) error {
existingClass, err := classes.ByName(name)
if err != nil {
return nil // No conflict if no class found or database error
}
if existingClass != nil && existingClass.ID != excludeID {
return fmt.Errorf("a class with the name '%s' already exists", name)
}
return nil
}
func adminClassSpells(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
class, err := classes.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Class %d not found", id))
ctx.Redirect("/admin/classes")
return
}
// Get current spell unlocks for this class
type SpellUnlock struct {
SpellID int
SpellName string
SpellType string
SpellMP int
SpellPower int
Level int
}
var unlocks []*SpellUnlock
err = database.Select(&unlocks, `
SELECT su.spell_id, s.name as spell_name, su.level,
s.mp as spell_mp, s.power as spell_power,
CASE s.type
WHEN 0 THEN 'Heal'
WHEN 1 THEN 'Damage'
WHEN 2 THEN 'Sleep'
WHEN 3 THEN 'Uber Attack'
WHEN 4 THEN 'Uber Defense'
ELSE 'Unknown'
END as spell_type
FROM spell_unlocks su
JOIN spells s ON su.spell_id = s.id
WHERE su.class_id = %d
ORDER BY su.level ASC, s.name ASC`, id)
if err != nil {
fmt.Printf("Error getting spell unlocks: %s", err.Error())
unlocks = make([]*SpellUnlock, 0)
}
components.RenderAdminPage(ctx, fmt.Sprintf("Spell Learning: %s", class.Name), "admin/classes/spells.html", map[string]any{
"class": class,
"currentUnlocks": unlocks,
"classID": id,
})
}
func adminSpellsAPI(ctx sushi.Ctx) {
allSpells, err := spells.All()
if err != nil {
ctx.SetStatusCode(500)
ctx.WriteString("Error loading spells")
return
}
ctx.SetContentType("application/json")
spellsJSON, _ := json.Marshal(allSpells)
ctx.Write(spellsJSON)
}
func adminClassSpellsUpdate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
class, err := classes.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Class %d not found", id))
ctx.Redirect("/admin/classes")
return
}
action := ctx.Form("action").String()
switch action {
case "add":
spellID := ctx.Form("spell_id").Int()
level := ctx.Form("level").Int()
if spellID == 0 {
sess.SetFlash("error", "Please select a spell")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
if level < 1 || level > 50 {
sess.SetFlash("error", "Level must be between 1 and 50")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
// Check if this spell is already unlocked for this class
var count int
err = database.Get(&count, "SELECT COUNT(*) FROM spell_unlocks WHERE class_id = %d AND spell_id = %d", id, spellID)
if err == nil && count > 0 {
sess.SetFlash("error", "This spell is already unlocked for this class")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
// Verify spell exists
spell, err := spells.Find(spellID)
if err != nil {
sess.SetFlash("error", "Spell not found")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
// Add the spell unlock
err = database.Exec("INSERT INTO spell_unlocks (spell_id, class_id, level) VALUES (%d, %d, %d)", spellID, id, level)
if err != nil {
sess.SetFlash("error", "Failed to add spell unlock")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Added %s to %s at level %d", spell.Name, class.Name, level))
case "remove":
spellID := ctx.Form("spell_id").Int()
level := ctx.Form("level").Int()
err = database.Exec("DELETE FROM spell_unlocks WHERE class_id = %d AND spell_id = %d AND level = %d", id, spellID, level)
if err != nil {
sess.SetFlash("error", "Failed to remove spell unlock")
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
return
}
sess.SetFlash("success", "Spell unlock removed")
default:
sess.SetFlash("error", "Invalid action")
}
ctx.Redirect(fmt.Sprintf("/admin/classes/%d/spells", id))
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}

View File

@ -246,10 +246,10 @@ INSERT INTO spells VALUES
(17, 4, 'Ward', '', '', 10, 10),
(18, 4, 'Fend', '', '', 20, 25),
(19, 4, 'Barrier', '', '', 30, 50),
(20, 2, 'Spark', 'Small jolt of electric energy.', '', 5, 10),
(21, 2, 'Firebolt', 'Blast of concentrated fire.', '', 10, 30),
(22, 2, 'Geyser', 'Explosion of high-pressure water.', '', 15, 60),
(23, 2, 'Magic Missile', 'Fast, tracking bolt of arcane force.', '', 20, 85);
(20, 1, 'Spark', 'Small jolt of electric energy.', '', 5, 10),
(21, 1, 'Firebolt', 'Blast of concentrated fire.', '', 10, 30),
(22, 1, 'Geyser', 'Explosion of high-pressure water.', '', 15, 60),
(23, 1, 'Magic Missile', 'Fast, tracking bolt of arcane force.', '', 20, 85);
CREATE TABLE spell_unlocks (
`spell_id` INTEGER NOT NULL,

View File

@ -0,0 +1,80 @@
{include "admin/layout.html"}
{block "content"}
<h1>{if class.ID}Edit Class: {class.Name}{else}Add New Class{/if}</h1>
<form class="standard" method="post">
{csrf}
<div>
<label for="name">Class Name:</label>
<input type="text" name="name" id="name" value="{class.Name}" required>
</div>
<div>
<label for="lore">Lore/Description:</label>
<textarea name="lore" id="lore" rows="3" placeholder="Class description or background lore">{class.Lore}</textarea>
</div>
<fieldset style="margin: 1rem 0;">
<legend>Base Stats (Level 1)</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="base_hp">Base HP:</label>
<input type="number" name="base_hp" id="base_hp" value="{class.BaseHP}" min="1" required>
<small>Starting health points</small>
</div>
<div>
<label for="base_mp">Base MP:</label>
<input type="number" name="base_mp" id="base_mp" value="{class.BaseMP}" min="0" required>
<small>Starting magic points</small>
</div>
<div>
<label for="base_str">Base Strength:</label>
<input type="number" name="base_str" id="base_str" value="{class.BaseSTR}" min="0" required>
<small>Starting strength</small>
</div>
<div>
<label for="base_dex">Base Dexterity:</label>
<input type="number" name="base_dex" id="base_dex" value="{class.BaseDEX}" min="0" required>
<small>Starting dexterity</small>
</div>
</div>
</fieldset>
<fieldset style="margin: 1rem 0;">
<legend>Growth Rates (Per Level)</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="rate_hp">HP Growth:</label>
<input type="number" name="rate_hp" id="rate_hp" value="{class.RateHP}" min="0" required>
<small>HP gained per level</small>
</div>
<div>
<label for="rate_mp">MP Growth:</label>
<input type="number" name="rate_mp" id="rate_mp" value="{class.RateMP}" min="0" required>
<small>MP gained per level</small>
</div>
<div>
<label for="rate_str">Strength Growth:</label>
<input type="number" name="rate_str" id="rate_str" value="{class.RateSTR}" min="0" required>
<small>Strength gained per level</small>
</div>
<div>
<label for="rate_dex">Dexterity Growth:</label>
<input type="number" name="rate_dex" id="rate_dex" value="{class.RateDEX}" min="0" required>
<small>Dexterity gained per level</small>
</div>
</div>
</fieldset>
<div>
<a href="/admin/classes"><button type="button" class="btn">Cancel</button></a>
{if class.ID}
<a href="/admin/classes/{class.ID}/spells"><button type="button" class="btn btn-secondary">Manage Spells</button></a>
{/if}
<button type="submit" class="btn btn-primary">{if class.ID}Update{else}Create{/if} Class</button>
{if class.ID}
<button type="submit" name="delete" value="1" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this class?')">Delete Class</button>
{/if}
</div>
</form>
{/block}

View File

@ -0,0 +1,62 @@
{include "admin/layout.html"}
{block "content"}
<h1>Class Management</h1>
<div style="margin-bottom: 1rem;">
<a href="/admin/classes/new" class="btn btn-primary">Add New Class</a>
</div>
<p>
Total classes: {#classes} | Page {currentPage} of {totalPages}
</p>
{if #classes > 0}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Base Stats</th>
<th>Growth Rates</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{for class in classes}
<tr>
<td>{class.ID}</td>
<td>{class.Name}</td>
<td>
HP: {class.BaseHP} | MP: {class.BaseMP}<br>
STR: {class.BaseSTR} | DEX: {class.BaseDEX}
</td>
<td>
HP: +{class.RateHP} | MP: +{class.RateMP}<br>
STR: +{class.RateSTR} | DEX: +{class.RateDEX}
</td>
<td>
<a href="/admin/classes/{class.ID}">Edit</a>
</td>
</tr>
{/for}
</tbody>
</table>
{if totalPages > 1}
<div class="pagination">
{if hasPrev}
<a href="/admin/classes?page={currentPage - 1}">← Previous</a>
{/if}
{if hasNext}
<a href="/admin/classes?page={currentPage + 1}">Next →</a>
{/if}
</div>
{/if}
{else}
<div>
No classes found.
</div>
{/if}
{/block}

View File

@ -0,0 +1,81 @@
{include "admin/layout.html"}
{block "content"}
<h1>Spell Learning: {class.Name}</h1>
<div style="margin-bottom: 1rem;">
<a href="/admin/classes/{class.ID}" class="btn">← Back to Class</a>
</div>
<!-- Current Spell Unlocks -->
<h2>Current Spell Unlocks</h2>
{if #currentUnlocks > 0}
<table>
<thead>
<tr>
<th>Level</th>
<th>Spell</th>
<th>Type</th>
<th>MP</th>
<th>Power</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{for unlock in currentUnlocks}
<tr>
<td>{unlock.Level}</td>
<td>{unlock.SpellName}</td>
<td>{unlock.SpellType}</td>
<td>{unlock.SpellMP}</td>
<td>{unlock.SpellPower}</td>
<td>
<form method="post" style="display: inline;">
{csrf}
<input type="hidden" name="action" value="remove">
<input type="hidden" name="spell_id" value="{unlock.SpellID}">
<input type="hidden" name="level" value="{unlock.Level}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Remove this spell unlock?')">Remove</button>
</form>
</td>
</tr>
{/for}
</tbody>
</table>
{else}
<p>No spells currently unlocked for this class.</p>
{/if}
<!-- Add New Spell Unlock -->
<h2>Add Spell Unlock</h2>
<form method="post" class="standard">
{csrf}
<input type="hidden" name="action" value="add">
<div>
<label for="spell-search">Search for Spell:</label>
<input type="text" id="spell-search" placeholder="Type spell name..." autocomplete="off">
<div id="spell-results" style="border: 1px solid #ccc; background: white; max-height: 200px; overflow-y: auto; display: none; position: relative; z-index: 1000;"></div>
</div>
<div>
<label for="selected-spell">Selected Spell:</label>
<div id="selected-spell" style="padding: 0.5rem; border: 1px solid #ccc; background: #f9f9f9; min-height: 2rem;">
<em>No spell selected</em>
</div>
<input type="hidden" name="spell_id" id="spell-id" required>
</div>
<div>
<label for="level">Learn at Level:</label>
<input type="number" name="level" id="level" min="1" max="50" value="1" required>
<small>Level at which this spell becomes available</small>
</div>
<div>
<button type="submit" class="btn btn-primary">Add Spell Unlock</button>
</div>
</form>
<script src="/assets/scripts/spell-search.js"></script>
{/block}

View File

@ -0,0 +1,82 @@
{include "admin/layout.html"}
{block "content"}
<h1>{if item.ID}Edit Item: {item.Name}{else}Add New Item{/if}</h1>
<form class="standard" method="post">
{csrf}
<div>
<label for="name">Item Name:</label>
<input type="text" name="name" id="name" value="{item.Name}" required>
</div>
<div>
<label for="type">Item Type:</label>
<select name="type" id="type" required>
<option value="1"{if item.Type == 1} selected{/if}>Weapon</option>
<option value="2"{if item.Type == 2} selected{/if}>Armor</option>
<option value="3"{if item.Type == 3} selected{/if}>Shield</option>
<option value="4"{if item.Type == 4} selected{/if}>Accessory</option>
</select>
</div>
<div>
<label for="value">Value (gold):</label>
<input type="number" name="value" id="value" value="{item.Value}" min="0" required>
</div>
<div>
<label for="lore">Lore/Description:</label>
<textarea name="lore" id="lore" rows="3" placeholder="Item description or lore text">{item.Lore}</textarea>
</div>
<fieldset style="margin: 1rem 0;">
<legend>Stat Bonuses</legend>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="attack">Attack:</label>
<input type="number" name="attack" id="attack" value="{item.Attack}">
</div>
<div>
<label for="defense">Defense:</label>
<input type="number" name="defense" id="defense" value="{item.Defense}">
</div>
<div>
<label for="strength">Strength:</label>
<input type="number" name="strength" id="strength" value="{item.Strength}">
</div>
<div>
<label for="dexterity">Dexterity:</label>
<input type="number" name="dexterity" id="dexterity" value="{item.Dexterity}">
</div>
<div>
<label for="max_hp">Max HP Bonus:</label>
<input type="number" name="max_hp" id="max_hp" value="{item.MaxHP}">
</div>
<div>
<label for="max_mp">Max MP Bonus:</label>
<input type="number" name="max_mp" id="max_mp" value="{item.MaxMP}">
</div>
<div>
<label for="exp_bonus">Exp Bonus (%):</label>
<input type="number" name="exp_bonus" id="exp_bonus" value="{item.ExpBonus}">
</div>
<div>
<label for="gold_bonus">Gold Bonus (%):</label>
<input type="number" name="gold_bonus" id="gold_bonus" value="{item.GoldBonus}">
</div>
</div>
</fieldset>
<div>
<label for="special">Special Properties:</label>
<input type="text" name="special" id="special" value="{item.Special}" placeholder="Special item properties or effects">
<small>Optional special properties or unique effects</small>
</div>
<div>
<a href="/admin/items"><button type="button" class="btn">Cancel</button></a>
<button type="submit" class="btn btn-primary">{if item.ID}Update{else}Create{/if} Item</button>
{if item.ID}
<button type="submit" name="delete" value="1" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this item?')">Delete Item</button>
{/if}
</div>
</form>
{/block}

View File

@ -0,0 +1,72 @@
{include "admin/layout.html"}
{block "content"}
<h1>Item Management</h1>
<div style="margin-bottom: 1rem;">
<a href="/admin/items/new" class="btn btn-primary">Add New Item</a>
</div>
<p>
Total items: {#items} | Page {currentPage} of {totalPages}
</p>
{if #items > 0}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Attack</th>
<th>Defense</th>
<th>Strength</th>
<th>Dexterity</th>
<th>HP/MP</th>
<th>Special</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{for item in items}
<tr>
<td>{item.ID}</td>
<td>{item.Name}</td>
<td>{item.TypeName}</td>
<td>{item.Value}g</td>
<td>{if item.Attack != 0}{item.Attack}{else}-{/if}</td>
<td>{if item.Defense != 0}{item.Defense}{else}-{/if}</td>
<td>{if item.Strength != 0}{item.Strength}{else}-{/if}</td>
<td>{if item.Dexterity != 0}{item.Dexterity}{else}-{/if}</td>
<td>
{if item.MaxHP != 0}{item.MaxHP}HP{/if}
{if item.MaxMP != 0}{if item.MaxHP != 0}/{/if}{item.MaxMP}MP{/if}
{if item.MaxHP == 0 && item.MaxMP == 0}-{/if}
</td>
<td>{if item.Special}{item.Special}{else}-{/if}</td>
<td>
<a href="/admin/items/{item.ID}">Edit</a>
</td>
</tr>
{/for}
</tbody>
</table>
{if totalPages > 1}
<div class="pagination">
{if hasPrev}
<a href="/admin/items?page={currentPage - 1}">← Previous</a>
{/if}
{if hasNext}
<a href="/admin/items?page={currentPage + 1}">Next →</a>
{/if}
</div>
{/if}
{else}
<div>
No items found.
</div>
{/if}
{/block}

View File

@ -27,6 +27,7 @@
<a href="/admin/towns">Edit Towns</a>
<a href="/admin/monsters">Edit Monsters</a>
<a href="/admin/spells">Edit Spells</a>
<a href="/admin/classes">Edit Classes</a>
</section>
<footer>