diff --git a/assets/scripts/spell-search.js b/assets/scripts/spell-search.js
new file mode 100644
index 0000000..e8d08db
--- /dev/null
+++ b/assets/scripts/spell-search.js
@@ -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 = '
No spells found
';
+ } else {
+ resultsDiv.innerHTML = scored.map((spell, index) =>
+ `
+ ${spell.Name} - ${getSpellTypeName(spell.Type)}
+ (${spell.MP} MP, ${spell.Power} Power)
+
`
+ ).join('');
+ }
+
+ resultsDiv.style.display = 'block';
+}
+
+function selectSpell(spell) {
+ selectedDiv.innerHTML = `
+ ${spell.Name} - ${getSpellTypeName(spell.Type)}
+ MP Cost: ${spell.MP} | Power: ${spell.Power}
+ ${spell.Lore ? `
${spell.Lore}` : ''}
+ `;
+ 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';
+ }
+});
\ No newline at end of file
diff --git a/data/dk.db b/data/dk.db
index eda1cbc..1d08f6c 100644
Binary files a/data/dk.db and b/data/dk.db differ
diff --git a/internal/models/classes/class.go b/internal/models/classes/class.go
index 67b0626..1180fac 100644
--- a/internal/models/classes/class.go
+++ b/internal/models/classes/class.go
@@ -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
+}
diff --git a/internal/models/items/items.go b/internal/models/items/items.go
index e306aad..11b9051 100644
--- a/internal/models/items/items.go
+++ b/internal/models/items/items.go
@@ -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
diff --git a/internal/routes/admin.go b/internal/routes/admin.go
index ead9f93..a0d38c2 100644
--- a/internal/routes/admin.go
+++ b/internal/routes/admin.go
@@ -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
}
diff --git a/sql/1_create_database.sql b/sql/1_create_database.sql
index cd7a57c..2d549c4 100644
--- a/sql/1_create_database.sql
+++ b/sql/1_create_database.sql
@@ -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,
diff --git a/templates/admin/classes/edit.html b/templates/admin/classes/edit.html
new file mode 100644
index 0000000..bd087a8
--- /dev/null
+++ b/templates/admin/classes/edit.html
@@ -0,0 +1,80 @@
+{include "admin/layout.html"}
+
+{block "content"}
+{if class.ID}Edit Class: {class.Name}{else}Add New Class{/if}
+
+
+{/block}
\ No newline at end of file
diff --git a/templates/admin/classes/index.html b/templates/admin/classes/index.html
new file mode 100644
index 0000000..5fe1da7
--- /dev/null
+++ b/templates/admin/classes/index.html
@@ -0,0 +1,62 @@
+{include "admin/layout.html"}
+
+{block "content"}
+Class Management
+
+
+
+
+ Total classes: {#classes} | Page {currentPage} of {totalPages}
+
+
+{if #classes > 0}
+
+
+
+ ID |
+ Name |
+ Base Stats |
+ Growth Rates |
+ Actions |
+
+
+
+ {for class in classes}
+
+ {class.ID} |
+ {class.Name} |
+
+ HP: {class.BaseHP} | MP: {class.BaseMP}
+ STR: {class.BaseSTR} | DEX: {class.BaseDEX}
+ |
+
+ HP: +{class.RateHP} | MP: +{class.RateMP}
+ STR: +{class.RateSTR} | DEX: +{class.RateDEX}
+ |
+
+ Edit
+ |
+
+ {/for}
+
+
+
+{if totalPages > 1}
+
+{/if}
+
+{else}
+
+ No classes found.
+
+{/if}
+{/block}
\ No newline at end of file
diff --git a/templates/admin/classes/spells.html b/templates/admin/classes/spells.html
new file mode 100644
index 0000000..d384ec5
--- /dev/null
+++ b/templates/admin/classes/spells.html
@@ -0,0 +1,81 @@
+{include "admin/layout.html"}
+
+{block "content"}
+Spell Learning: {class.Name}
+
+
+
+
+Current Spell Unlocks
+{if #currentUnlocks > 0}
+
+
+
+ Level |
+ Spell |
+ Type |
+ MP |
+ Power |
+ Actions |
+
+
+
+ {for unlock in currentUnlocks}
+
+ {unlock.Level} |
+ {unlock.SpellName} |
+ {unlock.SpellType} |
+ {unlock.SpellMP} |
+ {unlock.SpellPower} |
+
+
+ |
+
+ {/for}
+
+
+{else}
+No spells currently unlocked for this class.
+{/if}
+
+
+Add Spell Unlock
+
+
+
+{/block}
diff --git a/templates/admin/items/edit.html b/templates/admin/items/edit.html
new file mode 100644
index 0000000..dacef5d
--- /dev/null
+++ b/templates/admin/items/edit.html
@@ -0,0 +1,82 @@
+{include "admin/layout.html"}
+
+{block "content"}
+{if item.ID}Edit Item: {item.Name}{else}Add New Item{/if}
+
+
+{/block}
\ No newline at end of file
diff --git a/templates/admin/items/index.html b/templates/admin/items/index.html
new file mode 100644
index 0000000..681dde8
--- /dev/null
+++ b/templates/admin/items/index.html
@@ -0,0 +1,72 @@
+{include "admin/layout.html"}
+
+{block "content"}
+Item Management
+
+
+
+
+ Total items: {#items} | Page {currentPage} of {totalPages}
+
+
+{if #items > 0}
+
+
+
+ ID |
+ Name |
+ Type |
+ Value |
+ Attack |
+ Defense |
+ Strength |
+ Dexterity |
+ HP/MP |
+ Special |
+ Actions |
+
+
+
+ {for item in items}
+
+ {item.ID} |
+ {item.Name} |
+ {item.TypeName} |
+ {item.Value}g |
+ {if item.Attack != 0}{item.Attack}{else}-{/if} |
+ {if item.Defense != 0}{item.Defense}{else}-{/if} |
+ {if item.Strength != 0}{item.Strength}{else}-{/if} |
+ {if item.Dexterity != 0}{item.Dexterity}{else}-{/if} |
+
+ {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}
+ |
+ {if item.Special}{item.Special}{else}-{/if} |
+
+ Edit
+ |
+
+ {/for}
+
+
+
+{if totalPages > 1}
+
+{/if}
+
+{else}
+
+ No items found.
+
+{/if}
+{/block}
\ No newline at end of file
diff --git a/templates/admin/layout.html b/templates/admin/layout.html
index 9306d1b..78ff8ce 100644
--- a/templates/admin/layout.html
+++ b/templates/admin/layout.html
@@ -27,6 +27,7 @@
Edit Towns
Edit Monsters
Edit Spells
+ Edit Classes