feat(admin): implement delete functionality for Falukant regions and update UI components

This commit is contained in:
Torsten Schulz (local)
2026-08-14 09:54:09 +02:00
parent 3f80cc40ff
commit e6f6fc984a
7 changed files with 522 additions and 68 deletions

View File

@@ -57,6 +57,7 @@ class AdminController {
this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this);
this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this);
this.createFalukantRegion = this.createFalukantRegion.bind(this);
this.deleteFalukantRegion = this.deleteFalukantRegion.bind(this);
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
this.getRegionDistances = this.getRegionDistances.bind(this);
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
@@ -622,6 +623,19 @@ class AdminController {
}
}
async deleteFalukantRegion(req, res) {
try {
const { userid: userId } = req.headers;
const result = await AdminService.deleteFalukantRegion(userId, req.params.id);
res.status(200).json(result);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403
: (['regionNotFound'].includes(error.message) ? 404 : 400);
res.status(status).json({ error: error.message });
}
}
async updateFalukantRegionMap(req, res) {
try {
const { userid: userId } = req.headers;

View File

@@ -0,0 +1,63 @@
'use strict';
/**
* Older generated SRS data contained the number word "Baynte" as the
* currency phrase "20 Peso". Baynte itself means twenty, so the expected
* answer must be the number without a currency unit.
*/
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
WITH affected AS (
SELECT
id,
user_id,
course_id,
lesson_id,
direction,
CASE
WHEN LOWER(TRIM(learning)) = 'baynte' THEN 'baynte'
ELSE '20'
END AS corrected_learning,
CASE
WHEN LOWER(TRIM(reference)) = 'baynte' THEN 'baynte'
ELSE '20'
END AS corrected_reference
FROM community.vocab_srs_item
WHERE (LOWER(TRIM(learning)) = 'baynte' AND LOWER(TRIM(reference)) IN ('20 peso', '20 pesos'))
OR (LOWER(TRIM(reference)) = 'baynte' AND LOWER(TRIM(learning)) IN ('20 peso', '20 pesos'))
),
keyed AS (
SELECT
affected.*,
ENCODE(DIGEST(CONCAT_WS(
'|',
course_id::text,
COALESCE(lesson_id::text, 'course'),
UPPER(direction),
corrected_learning,
corrected_reference
), 'sha1'), 'hex') AS corrected_item_key
FROM affected
)
UPDATE community.vocab_srs_item item
SET learning = keyed.corrected_learning,
reference = keyed.corrected_reference,
item_key = keyed.corrected_item_key,
updated_at = NOW()
FROM keyed
WHERE item.id = keyed.id
AND NOT EXISTS (
SELECT 1
FROM community.vocab_srs_item duplicate
WHERE duplicate.user_id = keyed.user_id
AND duplicate.item_key = keyed.corrected_item_key
AND duplicate.id <> keyed.id
);
`);
},
async down() {
// The old answer was semantically incorrect and must not be restored.
},
};

View File

@@ -65,6 +65,7 @@ router.get('/falukant/region-types', authenticate, adminController.getFalukantRe
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions);
router.post('/falukant/regions', authenticate, adminController.createFalukantRegion);
router.delete('/falukant/regions/:id', authenticate, adminController.deleteFalukantRegion);
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);

View File

@@ -934,6 +934,45 @@ class AdminService {
return region;
}
async deleteFalukantRegion(userId, regionId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const region = await RegionData.findByPk(regionId);
if (!region) {
throw new Error('regionNotFound');
}
const [childCount, distanceCount, userCount, characterCount, weatherCount] = await Promise.all([
RegionData.count({ where: { parentId: region.id } }),
RegionDistance.count({
where: {
[Op.or]: [
{ sourceRegionId: region.id },
{ targetRegionId: region.id },
],
},
}),
FalukantUser.count({ where: { mainBranchRegionId: region.id } }),
FalukantCharacter.count({ where: { regionId: region.id } }),
Weather.count({ where: { regionId: region.id } }),
]);
if (childCount > 0) {
throw new Error('regionHasChildren');
}
if (distanceCount > 0) {
throw new Error('regionHasDistances');
}
if (userCount > 0 || characterCount > 0 || weatherCount > 0) {
throw new Error('regionInUse');
}
await region.destroy();
return { success: true };
}
async getRegionDistances(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');

View File

@@ -262,10 +262,35 @@
"distances": "Entfernungen"
},
"regionList": "Regionen",
"mapArea": "Karten-Arbeitsbereich",
"selectedRegion": "Ausgewählte Region",
"selectRegionHint": "Wähle eine Region aus der Liste oder direkt auf der Karte aus.",
"unknownType": "Unbekannter Typ",
"coordsCompact": "X {x}, Y {y} · B {w} × H {h}",
"noCoords": "Keine Koordinaten gesetzt",
"positionSet": "Position gesetzt",
"coordinateX": "X-Position",
"coordinateY": "Y-Position",
"coordinateWidth": "Breite",
"coordinateHeight": "Höhe",
"currentRect": "Aktuelles Rechteck",
"hintDraw": "Wähle eine Region und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
"saveAll": "Alle geänderten Regionen speichern",
"saving": "Speichere…",
"saveSuccess": "Region wurde gespeichert.",
"saveAllSuccess": "Alle Änderungen wurden gespeichert.",
"errorSave": "Die Region konnte nicht gespeichert werden.",
"unsaved": "Nicht gespeichert",
"deleteRegion": "Region löschen",
"deleting": "Lösche…",
"deleteHint": "Das Löschen ist nur möglich, wenn keine Unterregionen oder Entfernungen vorhanden sind.",
"confirmDeleteRegion": "Region „{name}“ wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.",
"deleteSuccess": "Region wurde gelöscht.",
"deleteError": "Die Region konnte nicht gelöscht werden.",
"deleteBlockedChildren": "Diese Region kann nicht gelöscht werden, weil sie Unterregionen enthält.",
"deleteBlockedDistances": "Diese Region kann nicht gelöscht werden, weil sie in Entfernungsverbindungen verwendet wird.",
"deleteBlockedInUse": "Diese Region kann nicht gelöscht werden, weil sie bereits von Spiel- oder Wetterdaten verwendet wird.",
"deleteBlockedNotFound": "Die Region wurde bereits gelöscht oder existiert nicht mehr.",
"createRegion": {
"title": "Neue Region anlegen",
"type": "Regionstyp",
@@ -276,7 +301,8 @@
"name": "Name",
"create": "Region anlegen",
"creating": "Lege an…",
"error": "Region konnte nicht angelegt werden."
"error": "Region konnte nicht angelegt werden.",
"success": "Region wurde angelegt."
},
"connectionsTitle": "Verbindungen (region_distance)",
"source": "Von",

View File

@@ -317,10 +317,35 @@
"distances": "Distances"
},
"regionList": "Regions",
"mapArea": "Map workspace",
"selectedRegion": "Selected region",
"selectRegionHint": "Select a region from the list or directly on the map.",
"unknownType": "Unknown type",
"coordsCompact": "X {x}, Y {y} · W {w} × H {h}",
"noCoords": "No coordinates set",
"positionSet": "Position set",
"coordinateX": "X position",
"coordinateY": "Y position",
"coordinateWidth": "Width",
"coordinateHeight": "Height",
"currentRect": "Current rectangle",
"hintDraw": "Select a region and drag a rectangle on the map to set its position.",
"saveAll": "Save all changed regions",
"saving": "Saving…",
"saveSuccess": "Region saved.",
"saveAllSuccess": "All changes saved.",
"errorSave": "Could not save the region.",
"unsaved": "Unsaved",
"deleteRegion": "Delete region",
"deleting": "Deleting…",
"deleteHint": "Deletion is available only when the region has no child regions or distance connections.",
"confirmDeleteRegion": "Delete region “{name}”? This cannot be undone.",
"deleteSuccess": "Region deleted.",
"deleteError": "Could not delete the region.",
"deleteBlockedChildren": "This region cannot be deleted because it has child regions.",
"deleteBlockedDistances": "This region cannot be deleted because it is used by distance connections.",
"deleteBlockedInUse": "This region cannot be deleted because it is already used by game or weather data.",
"deleteBlockedNotFound": "This region has already been deleted or no longer exists.",
"createRegion": {
"title": "Create new region",
"type": "Region type",
@@ -331,7 +356,8 @@
"name": "Name",
"create": "Create region",
"creating": "Creating…",
"error": "Could not create region."
"error": "Could not create region.",
"success": "Region created."
},
"connectionsTitle": "Connections (region_distance)",
"source": "From",

View File

@@ -6,13 +6,18 @@
</div>
<div class="map-layout">
<div
class="map-container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<section class="map-workspace" :aria-label="$t('admin.falukant.map.mapArea')">
<div class="map-instruction">
<span class="instruction-marker" aria-hidden="true">1</span>
{{ $t('admin.falukant.map.hintDraw') }}
</div>
<div
class="map-container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<img
ref="mapImage"
src="/images/falukant/map.png"
@@ -39,24 +44,26 @@
class="region-rect drawing"
:style="rectStyle(drawingRect)"
></div>
</div>
</div>
</section>
<div class="sidebar">
<aside class="sidebar">
<h2>{{ $t('admin.falukant.map.regionList') }}</h2>
<ul class="region-list">
<li
v-for="region in regions"
:key="region.id"
:class="regionListClasses(region)"
@click="selectRegion(region)"
>
{{ region.name }}
<span v-if="region.map" class="coords">
({{ region.map.x }},{{ region.map.y }} {{ region.map.w }}×{{ region.map.h }})
</span>
<span v-else class="coords missing">
{{ $t('admin.falukant.map.noCoords') }}
</span>
<button type="button" class="region-list-entry" @click="selectRegion(region)">
<span class="region-list-name">{{ region.name }}</span>
<span class="type-badge">{{ regionTypeName(region) }}</span>
<span v-if="region.map" class="coords">
{{ $t('admin.falukant.map.coordsCompact', { x: region.map.x, y: region.map.y, w: region.map.w, h: region.map.h }) }}
</span>
<span v-else class="coords missing">{{ $t('admin.falukant.map.noCoords') }}</span>
<span v-if="dirtyRegionIds.includes(region.id)" class="unsaved-badge">{{ $t('admin.falukant.map.unsaved') }}</span>
</button>
</li>
</ul>
@@ -115,34 +122,42 @@
</table>
</div>
<div v-if="selectedRegion" class="details">
<h3>{{ selectedRegion.name }}</h3>
<p v-if="selectedRegion.map">
{{ $t('admin.falukant.map.currentRect') }}:
{{ selectedRegion.map.x }},{{ selectedRegion.map.y }}
{{ selectedRegion.map.w }}×{{ selectedRegion.map.h }}
</p>
<p v-else>
{{ $t('admin.falukant.map.noCoords') }}
</p>
<p class="hint">
{{ $t('admin.falukant.map.hintDraw') }}
</p>
<button
class="btn btn-primary"
:disabled="!selectedRegionDirty"
@click="saveSelectedRegion"
>
{{ $t('common.save') }}
</button>
<button
class="btn btn-secondary"
:disabled="!dirtyRegionIds.length"
@click="saveAllRegions"
>
{{ $t('admin.falukant.map.saveAll') }}
</button>
</div>
<section v-if="selectedRegion" class="details" :aria-label="$t('admin.falukant.map.selectedRegion')">
<div class="detail-heading">
<div>
<p class="eyebrow">{{ $t('admin.falukant.map.selectedRegion') }}</p>
<h3>{{ selectedRegion.name }}</h3>
</div>
<span class="type-badge">{{ regionTypeName(selectedRegion) }}</span>
</div>
<div class="selection-state">
<span :class="['position-status', { missing: !selectedRegion.map }]">
{{ selectedRegion.map ? $t('admin.falukant.map.positionSet') : $t('admin.falukant.map.noCoords') }}
</span>
<span v-if="selectedRegionDirty" class="unsaved-badge">{{ $t('admin.falukant.map.unsaved') }}</span>
</div>
<dl v-if="selectedRegion.map" class="coordinates-grid">
<div><dt>{{ $t('admin.falukant.map.coordinateX') }}</dt><dd>{{ selectedRegion.map.x }}</dd></div>
<div><dt>{{ $t('admin.falukant.map.coordinateY') }}</dt><dd>{{ selectedRegion.map.y }}</dd></div>
<div><dt>{{ $t('admin.falukant.map.coordinateWidth') }}</dt><dd>{{ selectedRegion.map.w }}</dd></div>
<div><dt>{{ $t('admin.falukant.map.coordinateHeight') }}</dt><dd>{{ selectedRegion.map.h }}</dd></div>
</dl>
<div class="detail-actions">
<button class="btn btn-primary" :disabled="!selectedRegionDirty || savingRegion" @click="saveSelectedRegion">
{{ savingRegion ? $t('admin.falukant.map.saving') : $t('common.save') }}
</button>
<button class="btn btn-secondary" :disabled="!dirtyRegionIds.length || savingAllRegions" @click="saveAllRegions">
{{ savingAllRegions ? $t('admin.falukant.map.saving') : $t('admin.falukant.map.saveAll') }}
</button>
</div>
<div class="danger-zone">
<span>{{ $t('admin.falukant.map.deleteHint') }}</span>
<button class="btn btn-danger" :disabled="deletingRegion" @click="deleteSelectedRegion">
{{ deletingRegion ? $t('admin.falukant.map.deleting') : $t('admin.falukant.map.deleteRegion') }}
</button>
</div>
</section>
<p v-else class="empty-selection">{{ $t('admin.falukant.map.selectRegionHint') }}</p>
</div>
<div v-else-if="activeTab === 'distances'" class="connections">
@@ -261,7 +276,7 @@
</tbody>
</table>
</div>
</div>
</aside>
</div>
</div>
</template>
@@ -269,7 +284,7 @@
<script>
import apiClient from '@/utils/axios.js';
import SimpleTabs from '@/components/SimpleTabs.vue';
import { confirmAction, showError } from '@/utils/feedback.js';
import { confirmAction, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'AdminFalukantMapRegionsView',
@@ -306,6 +321,9 @@ export default {
},
creatingRegion: false,
createRegionError: null,
savingRegion: false,
savingAllRegions: false,
deletingRegion: false,
};
},
computed: {
@@ -375,6 +393,10 @@ export default {
}
return raw;
},
regionTypeName(region) {
return this.regionTypeLabel(region?.regionType?.labelTr)
|| this.$t('admin.falukant.map.unknownType');
},
async loadRegions() {
try {
const { data } = await apiClient.get('/api/admin/falukant/regions/all');
@@ -422,6 +444,7 @@ export default {
if (this.parentRegionEnabled) {
this.newRegion.parentId = null;
}
showSuccess(this, this.$t('admin.falukant.map.createRegion.success'));
} catch (error) {
console.error('Error creating region:', error);
const errKey = error?.response?.data?.error;
@@ -577,6 +600,7 @@ export default {
},
async saveSelectedRegion() {
if (!this.selectedRegion || !this.selectedRegion.map) return;
this.savingRegion = true;
try {
await this.saveRegion(this.selectedRegion);
// Liste aktualisieren, damit andere Einträge auch aktualisierte Daten sehen
@@ -585,12 +609,17 @@ export default {
if (refreshed) {
this.selectedRegion = refreshed;
}
showSuccess(this, this.$t('admin.falukant.map.saveSuccess'));
} catch (error) {
console.error('Error saving region map:', error);
showError(this, this.$t('admin.falukant.map.errorSave'));
} finally {
this.savingRegion = false;
}
},
async saveAllRegions() {
if (!this.dirtyRegionIds.length) return;
this.savingAllRegions = true;
try {
const dirtyIds = [...this.dirtyRegionIds];
for (const id of dirtyIds) {
@@ -605,8 +634,45 @@ export default {
this.selectedRegion = refreshed;
}
}
showSuccess(this, this.$t('admin.falukant.map.saveAllSuccess'));
} catch (error) {
console.error('Error saving all region maps:', error);
showError(this, this.$t('admin.falukant.map.errorSave'));
} finally {
this.savingAllRegions = false;
}
},
async deleteSelectedRegion() {
const region = this.selectedRegion;
if (!region || this.deletingRegion) return;
const confirmed = await confirmAction(this, {
title: this.$t('admin.falukant.map.deleteRegion'),
message: this.$t('admin.falukant.map.confirmDeleteRegion', { name: region.name }),
});
if (!confirmed) return;
this.deletingRegion = true;
try {
await apiClient.delete(`/api/admin/falukant/regions/${region.id}`);
await Promise.all([this.loadRegions(), this.loadConnections()]);
this.selectedRegion = null;
this.selectedRegionDirty = false;
this.dirtyRegionIds = [];
this.drawingRect = null;
showSuccess(this, this.$t('admin.falukant.map.deleteSuccess'));
} catch (error) {
console.error('Error deleting Falukant region:', error);
const errorKey = error?.response?.data?.error;
const messages = {
regionHasChildren: 'deleteBlockedChildren',
regionHasDistances: 'deleteBlockedDistances',
regionInUse: 'deleteBlockedInUse',
regionNotFound: 'deleteBlockedNotFound',
};
showError(this, this.$t(`admin.falukant.map.${messages[errorKey] || 'deleteError'}`));
} finally {
this.deletingRegion = false;
}
},
async saveConnection() {
@@ -647,22 +713,58 @@ export default {
<style scoped>
.falukant-map-admin {
padding: 10px;
padding: 12px;
color: #24313d;
}
.map-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 16px;
align-items: start;
}
.map-workspace {
min-width: 0;
}
.map-instruction {
display: flex;
gap: 20px;
gap: 8px;
align-items: center;
margin-bottom: 8px;
padding: 8px 10px;
border-left: 3px solid #1976d2;
background: #f4f8fc;
color: #41556a;
font-size: 0.85rem;
}
.instruction-marker {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 19px;
height: 19px;
border-radius: 50%;
background: #1976d2;
color: #fff;
font-weight: 700;
font-size: 0.72rem;
}
.map-container {
position: relative;
display: inline-block;
max-width: 100%;
line-height: 0;
}
.map {
display: block;
width: 100%;
max-width: 800px;
border: 1px solid #ccc;
border: 1px solid #bac6d0;
}
.region-rect {
@@ -673,8 +775,9 @@ export default {
}
.region-rect.selected {
border-color: rgba(255, 128, 0, 0.9);
background-color: rgba(255, 128, 0, 0.25);
border-color: rgba(20, 97, 173, 1);
background-color: rgba(25, 118, 210, 0.28);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.75);
}
.region-rect.drawing {
@@ -682,8 +785,10 @@ export default {
}
.sidebar {
min-width: 260px;
max-width: 320px;
min-width: 0;
padding: 12px;
border: 1px solid #d7dee5;
background: #fbfcfd;
}
.region-list {
@@ -692,24 +797,48 @@ export default {
margin: 0 0 1rem 0;
max-height: 400px;
overflow: auto;
border: 1px solid #ccc;
border: 1px solid #cbd5df;
background: #fff;
}
.region-list li {
padding: 4px 8px;
border-bottom: 1px solid #edf0f3;
}
.region-list li:last-child {
border-bottom: 0;
}
.region-list-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 8px;
width: 100%;
padding: 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.region-list li:nth-child(odd) {
background-color: #f8f8f8;
.region-list-entry:hover,
.region-list-entry:focus-visible {
background: #eef6ff;
outline: none;
}
.region-list li.selected {
background-color: #e0f0ff;
font-weight: bold;
.region-list li.selected .region-list-entry {
background-color: #dcecff;
box-shadow: inset 3px 0 0 #1976d2;
}
.region-list-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.region-rect.source,
@@ -724,10 +853,11 @@ export default {
}
.region-list li.dirty {
font-style: italic;
font-style: normal;
}
.coords {
grid-column: 1 / -1;
font-size: 0.8rem;
color: #555;
}
@@ -737,7 +867,129 @@ export default {
}
.details {
margin-top: 1rem;
margin-top: 12px;
padding: 12px;
border: 1px solid #b9d5ef;
border-left: 3px solid #1976d2;
background: #fff;
}
.detail-heading,
.selection-state,
.detail-actions,
.danger-zone {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.detail-heading h3 {
margin: 0;
font-size: 1.1rem;
}
.eyebrow {
margin: 0 0 2px;
color: #657786;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.type-badge,
.unsaved-badge,
.position-status {
display: inline-block;
width: fit-content;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.72rem;
font-weight: 700;
line-height: 1.3;
}
.type-badge {
background: #e8eef4;
color: #34495d;
}
.unsaved-badge {
background: #fff1ce;
color: #815700;
}
.position-status {
margin-top: 10px;
background: #eaf4ff;
color: #1765a9;
}
.position-status.missing {
background: #f4f0e8;
color: #6b5a39;
}
.coordinates-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
margin: 12px 0;
background: #dfe6ec;
}
.coordinates-grid > div {
min-width: 0;
padding: 6px;
background: #f8fafb;
}
.coordinates-grid dt {
color: #687986;
font-size: 0.68rem;
font-weight: 700;
}
.coordinates-grid dd {
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
font-size: 0.78rem;
}
.detail-actions {
justify-content: flex-start;
}
.danger-zone {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e6d8d8;
color: #7a4a4a;
font-size: 0.78rem;
}
.btn-danger {
margin-left: auto;
border-color: #c84646;
background: #fff;
color: #a52e2e;
}
.btn-danger:hover:not(:disabled),
.btn-danger:focus-visible {
background: #c84646;
color: #fff;
}
.empty-selection {
margin: 12px 0 0;
padding: 10px;
border: 1px dashed #c5d0da;
color: #627482;
font-size: 0.85rem;
}
.create-region {
@@ -811,4 +1063,37 @@ export default {
.btn.mini.icon {
}
@media (max-width: 900px) {
.map-layout {
grid-template-columns: 1fr;
}
.sidebar {
max-width: none;
}
.region-list {
max-height: 260px;
}
}
@media (max-width: 480px) {
.falukant-map-admin {
padding: 8px;
}
.coordinates-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.danger-zone {
align-items: flex-start;
flex-direction: column;
}
.btn-danger {
margin-left: 0;
}
}
</style>