Compare commits

..

2 Commits

8 changed files with 711 additions and 87 deletions

Binary file not shown.

92
internal/actions/town.go Normal file
View File

@ -0,0 +1,92 @@
package actions
import (
"dk/internal/database"
"dk/internal/helpers"
"dk/internal/models/items"
"dk/internal/models/towns"
"dk/internal/models/users"
"fmt"
"maps"
)
// RestAtInn handles the inn resting logic
func RestAtInn(user *users.User, town *towns.Town) error {
if user.Gold < town.InnCost {
return fmt.Errorf("you can't afford to stay here tonight")
}
return database.Transaction(func() error {
return database.Update("users", map[string]any{
"gold": user.Gold - town.InnCost,
"hp": user.MaxHP,
"mp": user.MaxMP,
"tp": user.MaxTP,
}, "id", user.ID)
})
}
// BuyShopItem handles purchasing an item from a town shop
func BuyShopItem(user *users.User, town *towns.Town, itemID int) error {
// Validate item exists in shop
if !town.HasShopItem(itemID) {
return fmt.Errorf("the item doesn't exist in this shop")
}
// Get item details
item, err := items.Find(itemID)
if err != nil {
return fmt.Errorf("error purchasing item: %v", err)
}
// Check user has enough gold
if user.Gold < item.Value {
return fmt.Errorf("you don't have enough gold to buy %s", item.Name)
}
// Get equipment updates from existing actions
equipUpdates, err := UserEquipItem(user, item)
if err != nil {
return fmt.Errorf("cannot equip item: %v", err)
}
// Execute purchase transaction
return database.Transaction(func() error {
// Start with gold deduction
updates := map[string]any{
"gold": user.Gold - item.Value,
}
// Add equipment updates
maps.Copy(updates, equipUpdates)
return database.Update("users", updates, "id", user.ID)
})
}
// BuyTownMap handles purchasing a town map
func BuyTownMap(user *users.User, townID int) error {
// Get the town being mapped
mappedTown, err := towns.Find(townID)
if err != nil {
return fmt.Errorf("error purchasing map: %v", err)
}
// Check user has enough gold
if user.Gold < mappedTown.MapCost {
return fmt.Errorf("you don't have enough gold to buy the map to %s", mappedTown.Name)
}
// Get current town IDs and add new one
townIDs := user.GetTownIDs()
townIDs = append(townIDs, townID)
newTownsString := helpers.IntsToString(townIDs)
// Execute purchase transaction
return database.Transaction(func() error {
return database.Update("users", map[string]any{
"gold": user.Gold - mappedTown.MapCost,
"towns": newTownsString,
}, "id", user.ID)
})
}

View File

@ -5,6 +5,8 @@ import (
"dk/internal/database"
"dk/internal/helpers"
"dk/internal/models/news"
"dk/internal/models/spells"
"dk/internal/models/towns"
"dk/internal/models/users"
"fmt"
"runtime"
@ -34,6 +36,16 @@ func RegisterAdminRoutes(app *sushi.App) {
group.Get("/users", adminUsersIndex)
group.Get("/users/:id", adminUserEdit)
group.Post("/users/:id", adminUserUpdate)
group.Get("/towns", adminTownsIndex)
group.Get("/towns/new", adminTownNew)
group.Post("/towns/new", adminTownCreate)
group.Get("/towns/:id", adminTownEdit)
group.Post("/towns/:id", adminTownUpdate)
group.Get("/spells", adminSpellsIndex)
group.Get("/spells/new", adminSpellNew)
group.Post("/spells/new", adminSpellCreate)
group.Get("/spells/:id", adminSpellEdit)
group.Post("/spells/:id", adminSpellUpdate)
}
func adminIndex(ctx sushi.Ctx) {
@ -211,6 +223,393 @@ func adminUserUpdate(ctx sushi.Ctx) {
ctx.Redirect("/admin/users")
}
func adminTownsIndex(ctx sushi.Ctx) {
pagination := helpers.Pagination{
Page: max(int(ctx.QueryArgs().GetUintOrZero("page")), 1),
PerPage: 30,
}
type TownData struct {
ID int
Name string
X int
Y int
InnCost int
MapCost int
TPCost int
ShopList string
ShopItemCount int
}
var townList []*TownData
err := database.Select(&townList, `
SELECT id, name, x, y, inn_cost, map_cost, tp_cost, shop_list,
CASE
WHEN shop_list = '' THEN 0
ELSE (LENGTH(shop_list) - LENGTH(REPLACE(shop_list, ',', '')) + 1)
END as shop_item_count
FROM towns
ORDER BY id ASC
LIMIT %d OFFSET %d`, pagination.PerPage, pagination.Offset())
if err != nil {
fmt.Printf("Error getting town list for admin index: %s", err.Error())
townList = make([]*TownData, 0)
}
type CountResult struct{ Count int }
var result CountResult
database.Get(&result, "SELECT COUNT(*) as count FROM towns")
pagination.Total = result.Count
components.RenderAdminPage(ctx, "Town Management", "admin/towns/index.html", map[string]any{
"towns": townList,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
})
}
func adminTownNew(ctx sushi.Ctx) {
town := towns.New()
components.RenderAdminPage(ctx, "Add New Town", "admin/towns/edit.html", map[string]any{
"town": town,
})
}
func adminTownCreate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
town := towns.New()
if err := populateTownFromForm(ctx, town); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/towns/new")
return
}
if err := town.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/towns/new")
return
}
if err := checkTownCoordinateConflict(town.X, town.Y, 0); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/towns/new")
return
}
if err := town.Insert(); err != nil {
sess.SetFlash("error", "Failed to create town")
ctx.Redirect("/admin/towns/new")
return
}
sess.SetFlash("success", fmt.Sprintf("Town %s created successfully", town.Name))
ctx.Redirect("/admin/towns")
}
func adminTownEdit(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
town, err := towns.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Town %d not found", id))
ctx.Redirect("/admin/towns")
return
}
components.RenderAdminPage(ctx, fmt.Sprintf("Edit Town: %s", town.Name), "admin/towns/edit.html", map[string]any{
"town": town,
})
}
func adminTownUpdate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
town, err := towns.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Town %d not found", id))
ctx.Redirect("/admin/towns")
return
}
// Check if delete was requested
if ctx.Form("delete").String() == "1" {
if err := town.Delete(); err != nil {
sess.SetFlash("error", "Failed to delete town")
ctx.Redirect(fmt.Sprintf("/admin/towns/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Town %s deleted successfully", town.Name))
ctx.Redirect("/admin/towns")
return
}
if err := populateTownFromForm(ctx, town); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/towns/%d", id))
return
}
if err := town.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/towns/%d", id))
return
}
if err := checkTownCoordinateConflict(town.X, town.Y, town.ID); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/towns/%d", id))
return
}
fields := map[string]any{
"name": town.Name,
"x": town.X,
"y": town.Y,
"inn_cost": town.InnCost,
"map_cost": town.MapCost,
"tp_cost": town.TPCost,
"shop_list": town.ShopList,
}
if err := database.Update("towns", fields, "id", id); err != nil {
sess.SetFlash("error", "Failed to update town")
ctx.Redirect(fmt.Sprintf("/admin/towns/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Town %s updated successfully", town.Name))
ctx.Redirect("/admin/towns")
}
func populateTownFromForm(ctx sushi.Ctx, town *towns.Town) error {
town.Name = strings.TrimSpace(ctx.Form("name").String())
town.X = ctx.Form("x").Int()
town.Y = ctx.Form("y").Int()
town.InnCost = ctx.Form("inn_cost").Int()
town.MapCost = ctx.Form("map_cost").Int()
town.TPCost = ctx.Form("tp_cost").Int()
town.ShopList = strings.TrimSpace(ctx.Form("shop_list").String())
if town.Name == "" {
return fmt.Errorf("town name is required")
}
return nil
}
func checkTownCoordinateConflict(x, y, excludeID int) error {
existingTown, err := towns.ByCoords(x, y)
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
}
func adminSpellsIndex(ctx sushi.Ctx) {
pagination := helpers.Pagination{
Page: max(int(ctx.QueryArgs().GetUintOrZero("page")), 1),
PerPage: 30,
}
type SpellData struct {
ID int
Name string
Type int
TypeName string
MP int
Power int
Icon string
Lore string
}
var spellList []*SpellData
err := database.Select(&spellList, `
SELECT id, name, type, mp, power, icon, lore,
CASE 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 type_name
FROM spells
ORDER BY type ASC, mp ASC, id ASC
LIMIT %d OFFSET %d`, pagination.PerPage, pagination.Offset())
if err != nil {
fmt.Printf("Error getting spell list for admin index: %s", err.Error())
spellList = make([]*SpellData, 0)
}
type CountResult struct{ Count int }
var result CountResult
database.Get(&result, "SELECT COUNT(*) as count FROM spells")
pagination.Total = result.Count
components.RenderAdminPage(ctx, "Spell Management", "admin/spells/index.html", map[string]any{
"spells": spellList,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
})
}
func adminSpellNew(ctx sushi.Ctx) {
spell := spells.New()
components.RenderAdminPage(ctx, "Add New Spell", "admin/spells/edit.html", map[string]any{
"spell": spell,
})
}
func adminSpellCreate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
spell := spells.New()
if err := populateSpellFromForm(ctx, spell); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/spells/new")
return
}
if err := spell.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/spells/new")
return
}
if err := checkSpellNameConflict(spell.Name, 0); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect("/admin/spells/new")
return
}
if err := spell.Insert(); err != nil {
sess.SetFlash("error", "Failed to create spell")
ctx.Redirect("/admin/spells/new")
return
}
sess.SetFlash("success", fmt.Sprintf("Spell %s created successfully", spell.Name))
ctx.Redirect("/admin/spells")
}
func adminSpellEdit(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
spell, err := spells.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Spell %d not found", id))
ctx.Redirect("/admin/spells")
return
}
components.RenderAdminPage(ctx, fmt.Sprintf("Edit Spell: %s", spell.Name), "admin/spells/edit.html", map[string]any{
"spell": spell,
})
}
func adminSpellUpdate(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
spell, err := spells.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Spell %d not found", id))
ctx.Redirect("/admin/spells")
return
}
// Check if delete was requested
if ctx.Form("delete").String() == "1" {
if err := spell.Delete(); err != nil {
sess.SetFlash("error", "Failed to delete spell")
ctx.Redirect(fmt.Sprintf("/admin/spells/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Spell %s deleted successfully", spell.Name))
ctx.Redirect("/admin/spells")
return
}
if err := populateSpellFromForm(ctx, spell); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/spells/%d", id))
return
}
if err := spell.Validate(); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/spells/%d", id))
return
}
if err := checkSpellNameConflict(spell.Name, spell.ID); err != nil {
sess.SetFlash("error", err.Error())
ctx.Redirect(fmt.Sprintf("/admin/spells/%d", id))
return
}
fields := map[string]any{
"name": spell.Name,
"type": spell.Type,
"mp": spell.MP,
"power": spell.Power,
"icon": spell.Icon,
"lore": spell.Lore,
}
if err := database.Update("spells", fields, "id", id); err != nil {
sess.SetFlash("error", "Failed to update spell")
ctx.Redirect(fmt.Sprintf("/admin/spells/%d", id))
return
}
sess.SetFlash("success", fmt.Sprintf("Spell %s updated successfully", spell.Name))
ctx.Redirect("/admin/spells")
}
func populateSpellFromForm(ctx sushi.Ctx, spell *spells.Spell) error {
spell.Name = strings.TrimSpace(ctx.Form("name").String())
spell.Type = ctx.Form("type").Int()
spell.MP = ctx.Form("mp").Int()
spell.Power = ctx.Form("power").Int()
spell.Icon = strings.TrimSpace(ctx.Form("icon").String())
spell.Lore = strings.TrimSpace(ctx.Form("lore").String())
if spell.Name == "" {
return fmt.Errorf("spell name is required")
}
return nil
}
func checkSpellNameConflict(name string, excludeID int) error {
existingSpell, err := spells.ByName(name)
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
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}

View File

@ -3,9 +3,6 @@ package routes
import (
"dk/internal/actions"
"dk/internal/components"
"dk/internal/database"
"dk/internal/helpers"
"dk/internal/models/items"
"dk/internal/models/towns"
"dk/internal/models/users"
"fmt"
@ -95,22 +92,9 @@ func rest(ctx sushi.Ctx) {
town := ctx.UserValue("town").(*towns.Town)
user := ctx.GetCurrentUser().(*users.User)
if user.Gold < town.InnCost {
sess.SetFlash("error", "You can't afford to stay here tonight.")
ctx.Redirect("/town/inn")
return
}
err := database.Transaction(func() error {
return database.Update("users", map[string]any{
"gold": user.Gold - town.InnCost,
"hp": user.MaxHP,
"mp": user.MaxMP,
"tp": user.MaxTP,
}, "id", user.ID)
})
err := actions.RestAtInn(user, town)
if err != nil {
sess.SetFlash("error", "Failed to rest at inn.")
sess.SetFlash("error", err.Error())
ctx.Redirect("/town/inn")
return
}
@ -144,54 +128,13 @@ func showShop(ctx sushi.Ctx) {
func buyItem(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
town := ctx.UserValue("town").(*towns.Town)
if !town.HasShopItem(id) {
sess.SetFlash("error", "The item doesn't exist in this shop.")
ctx.Redirect("/town/shop")
return
}
item, err := items.Find(id)
if err != nil {
sess.SetFlash("error", "Error purchasing item; "+err.Error())
ctx.Redirect("/town/shop")
return
}
user := ctx.GetCurrentUser().(*users.User)
if user.Gold < item.Value {
sess.SetFlash("error", "You don't have enough gold to buy "+item.Name)
ctx.Redirect("/town/shop")
return
}
// Get equipment updates from actions
equipUpdates, err := actions.UserEquipItem(user, item)
err := actions.BuyShopItem(user, town, id)
if err != nil {
sess.SetFlash("error", "Cannot equip item: "+err.Error())
ctx.Redirect("/town/shop")
return
}
err = database.Transaction(func() error {
// Start with gold deduction
updates := map[string]any{
"gold": user.Gold - item.Value,
}
// Add equipment updates
for field, value := range equipUpdates {
updates[field] = value
}
return database.Update("users", updates, "id", user.ID)
})
if err != nil {
sess.SetFlash("error", "Failed to purchase item.")
sess.SetFlash("error", err.Error())
ctx.Redirect("/town/shop")
return
}
@ -224,36 +167,12 @@ func showMaps(ctx sushi.Ctx) {
func buyMap(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
mapped, err := towns.Find(id)
if err != nil {
sess.SetFlash("error", "Error purchasing map; "+err.Error())
ctx.Redirect("/town/maps")
return
}
user := ctx.GetCurrentUser().(*users.User)
if user.Gold < mapped.MapCost {
sess.SetFlash("error", "You don't have enough gold to buy the map to "+mapped.Name)
ctx.Redirect("/town/maps")
return
}
townIDs := user.GetTownIDs()
townIDs = append(townIDs, id)
newTownsString := helpers.IntsToString(townIDs)
err = database.Transaction(func() error {
return database.Update("users", map[string]any{
"gold": user.Gold - mapped.MapCost,
"towns": newTownsString,
}, "id", user.ID)
})
err := actions.BuyTownMap(user, id)
if err != nil {
sess.SetFlash("error", "Failed to purchase map.")
sess.SetFlash("error", err.Error())
ctx.Redirect("/town/maps")
return
}

View File

@ -0,0 +1,47 @@
{include "admin/layout.html"}
{block "content"}
<h1>{if spell.ID}Edit Spell: {spell.Name}{else}Add New Spell{/if}</h1>
<form class="standard" method="post">
{csrf}
<div>
<label for="name">Spell Name:</label>
<input type="text" name="name" id="name" value="{spell.Name}" required>
</div>
<div>
<label for="type">Spell Type:</label>
<select name="type" id="type" required>
<option value="0"{if spell.Type == 0} selected{/if}>Heal</option>
<option value="1"{if spell.Type == 1} selected{/if}>Damage</option>
<option value="2"{if spell.Type == 2} selected{/if}>Sleep</option>
<option value="3"{if spell.Type == 3} selected{/if}>Uber Attack</option>
<option value="4"{if spell.Type == 4} selected{/if}>Uber Defense</option>
</select>
</div>
<div>
<label for="mp">MP Cost:</label>
<input type="number" name="mp" id="mp" value="{spell.MP}" min="0" required>
</div>
<div>
<label for="power">Power:</label>
<input type="number" name="power" id="power" value="{spell.Power}" min="0" required>
</div>
<div>
<label for="icon">Icon:</label>
<input type="text" name="icon" id="icon" value="{spell.Icon}" placeholder="spell_icon.png">
<small>Optional icon filename or path</small>
</div>
<div>
<label for="lore">Lore/Description:</label>
<textarea name="lore" id="lore" rows="3" placeholder="Spell description or lore text">{spell.Lore}</textarea>
</div>
<div>
<a href="/admin/spells"><button type="button" class="btn">Cancel</button></a>
<button type="submit" class="btn btn-primary">{if spell.ID}Update{else}Create{/if} Spell</button>
{if spell.ID}
<button type="submit" name="delete" value="1" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this spell?')">Delete Spell</button>
{/if}
</div>
</form>
{/block}

View File

@ -0,0 +1,60 @@
{include "admin/layout.html"}
{block "content"}
<h1>Spell Management</h1>
<div style="margin-bottom: 1rem;">
<a href="/admin/spells/new" class="btn btn-primary">Add New Spell</a>
</div>
<p>
Total spells: {#spells} | Page {currentPage} of {totalPages}
</p>
{if #spells > 0}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>MP Cost</th>
<th>Power</th>
<th>Icon</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{for spell in spells}
<tr>
<td>{spell.ID}</td>
<td>{spell.Name}</td>
<td>{spell.TypeName}</td>
<td>{spell.MP}</td>
<td>{spell.Power}</td>
<td>{if spell.Icon}{spell.Icon}{else}-{/if}</td>
<td>
<a href="/admin/spells/{spell.ID}">Edit</a>
</td>
</tr>
{/for}
</tbody>
</table>
{if totalPages > 1}
<div class="pagination">
{if hasPrev}
<a href="/admin/spells?page={currentPage - 1}">← Previous</a>
{/if}
{if hasNext}
<a href="/admin/spells?page={currentPage + 1}">Next →</a>
{/if}
</div>
{/if}
{else}
<div>
No spells found.
</div>
{/if}
{/block}

View File

@ -0,0 +1,45 @@
{include "admin/layout.html"}
{block "content"}
<h1>{if town.ID}Edit Town: {town.Name}{else}Add New Town{/if}</h1>
<form class="standard" method="post">
{csrf}
<div>
<label for="name">Town Name:</label>
<input type="text" name="name" id="name" value="{town.Name}" required>
</div>
<div>
<label for="x">X Position:</label>
<input type="number" name="x" id="x" value="{town.X}" required>
</div>
<div>
<label for="y">Y Position:</label>
<input type="number" name="y" id="y" value="{town.Y}" required>
</div>
<div>
<label for="inn_cost">Inn Cost (gold):</label>
<input type="number" name="inn_cost" id="inn_cost" value="{town.InnCost}" min="0" required>
</div>
<div>
<label for="map_cost">Map Cost (gold):</label>
<input type="number" name="map_cost" id="map_cost" value="{town.MapCost}" min="0" required>
</div>
<div>
<label for="tp_cost">Teleport Cost (gold):</label>
<input type="number" name="tp_cost" id="tp_cost" value="{town.TPCost}" min="0" required>
</div>
<div>
<label for="shop_list">Shop Items (comma-separated item IDs):</label>
<input type="text" name="shop_list" id="shop_list" value="{town.ShopList}" placeholder="1,2,3">
<small>Enter item IDs separated by commas (e.g. "1,2,3,4")</small>
</div>
<div>
<a href="/admin/towns"><button type="button" class="btn">Cancel</button></a>
<button type="submit" class="btn btn-primary">{if town.ID}Update{else}Create{/if} Town</button>
{if town.ID}
<button type="submit" name="delete" value="1" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this town?')">Delete Town</button>
{/if}
</div>
</form>
{/block}

View File

@ -0,0 +1,62 @@
{include "admin/layout.html"}
{block "content"}
<h1>Town Management</h1>
<div style="margin-bottom: 1rem;">
<a href="/admin/towns/new" class="btn btn-primary">Add New Town</a>
</div>
<p>
Total towns: {#towns} | Page {currentPage} of {totalPages}
</p>
{if #towns > 0}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Position</th>
<th>Inn Cost</th>
<th>Map Cost</th>
<th>TP Cost</th>
<th>Shop Items</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{for town in towns}
<tr>
<td>{town.ID}</td>
<td>{town.Name}</td>
<td>({town.X}, {town.Y})</td>
<td>{town.InnCost}g</td>
<td>{town.MapCost}g</td>
<td>{town.TPCost}g</td>
<td>{town.ShopItemCount}</td>
<td>
<a href="/admin/towns/{town.ID}">Edit</a>
</td>
</tr>
{/for}
</tbody>
</table>
{if totalPages > 1}
<div class="pagination">
{if hasPrev}
<a href="/admin/towns?page={currentPage - 1}">← Previous</a>
{/if}
{if hasNext}
<a href="/admin/towns?page={currentPage + 1}">Next →</a>
{/if}
</div>
{/if}
{else}
<div>
No towns found.
</div>
{/if}
{/block}