Fügt Unterstützung für vordefinierte Aktivitäten hinzu, einschließlich der Möglichkeit, Bilder hochzuladen und zu suchen. Aktualisiert die Datenbankmodelle und -routen entsprechend. Verbessert die Benutzeroberfläche zur Anzeige und Bearbeitung von Aktivitäten in DiaryView.vue.

This commit is contained in:
Torsten Schulz (local)
2025-08-28 14:11:29 +02:00
parent c7325ac982
commit 244b61c901
12 changed files with 526 additions and 33 deletions

View File

@@ -1,9 +1,12 @@
import predefinedActivityService from '../services/predefinedActivityService.js';
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
import path from 'path';
import fs from 'fs';
export const createPredefinedActivity = async (req, res) => {
try {
const { name, description, durationText, duration } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, description, durationText, duration });
const { name, code, description, durationText, duration, imageLink } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink });
res.status(201).json(predefinedActivity);
} catch (error) {
console.error('[createPredefinedActivity] - Error:', error);
@@ -25,10 +28,11 @@ export const getPredefinedActivityById = async (req, res) => {
try {
const { id } = req.params;
const predefinedActivity = await predefinedActivityService.getPredefinedActivityById(id);
const images = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: id } });
if (!predefinedActivity) {
return res.status(404).json({ error: 'Predefined activity not found' });
}
res.status(200).json(predefinedActivity);
res.status(200).json({ ...predefinedActivity.toJSON(), images });
} catch (error) {
console.error('[getPredefinedActivityById] - Error:', error);
res.status(500).json({ error: 'Error fetching predefined activity' });
@@ -38,11 +42,22 @@ export const getPredefinedActivityById = async (req, res) => {
export const updatePredefinedActivity = async (req, res) => {
try {
const { id } = req.params;
const { name, description, durationText, duration } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, description, durationText, duration });
const { name, code, description, durationText, duration, imageLink } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink });
res.status(200).json(updatedActivity);
} catch (error) {
console.error('[updatePredefinedActivity] - Error:', error);
res.status(500).json({ error: 'Error updating predefined activity' });
}
};
export const searchPredefinedActivities = async (req, res) => {
try {
const { q, limit } = req.query;
const result = await predefinedActivityService.searchPredefinedActivities(q, limit);
res.status(200).json(result);
} catch (error) {
console.error('[searchPredefinedActivities] - Error:', error);
res.status(500).json({ error: 'Error searching predefined activities' });
}
};

View File

@@ -0,0 +1,53 @@
import PredefinedActivity from '../models/PredefinedActivity.js';
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
import { checkAccess } from '../utils/userUtils.js';
import path from 'path';
import fs from 'fs';
import sharp from 'sharp';
export const uploadPredefinedActivityImage = async (req, res) => {
try {
const { id } = req.params; // predefinedActivityId
const { authcode: userToken } = req.headers;
await checkAccess(userToken); // Club-Kontext ist hier nicht zwingend, falls gewünscht kann erweitert werden
const activity = await PredefinedActivity.findByPk(id);
if (!activity) {
return res.status(404).json({ error: 'Predefined activity not found' });
}
if (!req.file || !req.file.buffer) {
return res.status(400).json({ error: 'No image uploaded' });
}
const imagesDir = path.join('images', 'predefined');
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true });
}
const fileName = `${id}-${Date.now()}.jpg`;
const filePath = path.join(imagesDir, fileName);
await sharp(req.file.buffer)
.resize(800, 800, { fit: 'inside' })
.jpeg({ quality: 85 })
.toFile(filePath);
const imageRecord = await PredefinedActivityImage.create({
predefinedActivityId: id,
imagePath: filePath,
mimeType: 'image/jpeg',
});
// Optional: als imageLink am Activity-Datensatz setzen
activity.imageLink = `/api/predefined-activities/${id}/image/${imageRecord.id}`;
await activity.save();
res.status(201).json({ id: imageRecord.id, imageLink: activity.imageLink });
} catch (error) {
console.error('[uploadPredefinedActivityImage] - Error:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
};

View File

@@ -11,6 +11,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
type: DataTypes.STRING,
allowNull: false,
},
code: {
type: DataTypes.STRING,
allowNull: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
@@ -23,6 +27,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
type: DataTypes.INTEGER,
allowNull: true,
},
imageLink: {
type: DataTypes.STRING,
allowNull: true,
},
}, {
tableName: 'predefined_activities',
timestamps: true,

View File

@@ -0,0 +1,30 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const PredefinedActivityImage = sequelize.define('PredefinedActivityImage', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
predefinedActivityId: {
type: DataTypes.INTEGER,
allowNull: false,
},
imagePath: {
type: DataTypes.STRING,
allowNull: false,
},
mimeType: {
type: DataTypes.STRING,
allowNull: true,
},
}, {
tableName: 'predefined_activity_images',
timestamps: true,
underscored: true,
});
export default PredefinedActivityImage;

View File

@@ -13,6 +13,7 @@ import DiaryDateTag from './DiaryDateTag.js';
import DiaryMemberNote from './DiaryMemberNote.js';
import DiaryMemberTag from './DiaryMemberTag.js';
import PredefinedActivity from './PredefinedActivity.js';
import PredefinedActivityImage from './PredefinedActivityImage.js';
import DiaryDateActivity from './DiaryDateActivity.js';
import Match from './Match.js';
import League from './League.js';
@@ -76,6 +77,9 @@ DiaryDateActivity.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDa
PredefinedActivity.hasMany(DiaryDateActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivities' });
DiaryDateActivity.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
// PredefinedActivity Images
PredefinedActivity.hasMany(PredefinedActivityImage, { foreignKey: 'predefinedActivityId', as: 'images' });
PredefinedActivityImage.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
Club.hasMany(Match, { foreignKey: 'clubId', as: 'matches' });
Match.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
@@ -198,6 +202,7 @@ export {
DiaryMemberNote,
DiaryMemberTag,
PredefinedActivity,
PredefinedActivityImage,
DiaryDateActivity,
Match,
League,

View File

@@ -4,13 +4,35 @@ import {
getAllPredefinedActivities,
getPredefinedActivityById,
updatePredefinedActivity,
searchPredefinedActivities,
} from '../controllers/predefinedActivityController.js';
import multer from 'multer';
import { authenticate } from '../middleware/authMiddleware.js';
import { uploadPredefinedActivityImage } from '../controllers/predefinedActivityImageController.js';
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
import path from 'path';
import fs from 'fs';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
router.post('/', createPredefinedActivity);
router.get('/', getAllPredefinedActivities);
router.get('/:id', getPredefinedActivityById);
router.put('/:id', updatePredefinedActivity);
router.post('/', authenticate, createPredefinedActivity);
router.get('/', authenticate, getAllPredefinedActivities);
router.get('/:id', authenticate, getPredefinedActivityById);
router.put('/:id', authenticate, updatePredefinedActivity);
router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
router.get('/search/query', authenticate, searchPredefinedActivities);
router.get('/:id/image/:imageId', authenticate, async (req, res) => {
try {
const { id, imageId } = req.params;
const image = await PredefinedActivityImage.findOne({ where: { id: imageId, predefinedActivityId: id } });
if (!image) return res.status(404).json({ error: 'Image not found' });
if (!fs.existsSync(image.imagePath)) return res.status(404).json({ error: 'Image file missing' });
res.sendFile(path.resolve(image.imagePath));
} catch (e) {
console.error('[getPredefinedActivityImage] - Error:', e);
res.status(500).json({ error: 'Failed to fetch image' });
}
});
export default router;

View File

@@ -6,7 +6,7 @@ import cors from 'cors';
import {
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, DiaryDateActivity, Match, League, Team, Group,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, Match, League, Team, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken
} from './models/index.js';
@@ -88,6 +88,7 @@ app.get('*', (req, res) => {
await DiaryMemberTag.sync({ alter: true });
await DiaryMemberNote.sync({ alter: true });
await PredefinedActivity.sync({ alter: true });
await PredefinedActivityImage.sync({ alter: true });
await DiaryDateActivity.sync({ alter: true });
await Season.sync({ alter: true });
await League.sync({ alter: true });

View File

@@ -1,13 +1,16 @@
import PredefinedActivity from '../models/PredefinedActivity.js';
import { Op } from 'sequelize';
class PredefinedActivityService {
async createPredefinedActivity(data) {
console.log('[PredefinedActivityService::createPredefinedActivity] - Creating predefined activity');
return await PredefinedActivity.create({
name: data.name,
code: data.code,
description: data.description,
durationText: data.durationText,
duration: data.duration,
imageLink: data.imageLink,
});
}
@@ -20,9 +23,11 @@ class PredefinedActivityService {
}
return await activity.update({
name: data.name,
code: data.code,
description: data.description,
durationText: data.durationText,
duration: data.duration,
imageLink: data.imageLink,
});
}
@@ -40,6 +45,23 @@ class PredefinedActivityService {
}
return activity;
}
async searchPredefinedActivities(query, limit = 20) {
const q = (query || '').trim();
if (!q || q.length < 2) {
return [];
}
return await PredefinedActivity.findAll({
where: {
[Op.or]: [
{ name: { [Op.like]: `%${q}%` } },
{ code: { [Op.like]: `%${q}%` } },
],
},
order: [['name', 'ASC']],
limit: Math.min(parseInt(limit || 20, 10), 50),
});
}
}
export default new PredefinedActivityService();

View File

@@ -52,6 +52,10 @@
<span class="nav-icon">🏆</span>
Turniere
</a>
<a href="/predefined-activities" class="nav-link">
<span class="nav-icon"></span>
Vordefinierte Aktivitäten
</a>
</div>
</nav>

View File

@@ -11,6 +11,7 @@ import PendingApprovalsView from './views/PendingApprovalsView.vue';
import ScheduleView from './views/ScheduleView.vue';
import TournamentsView from './views/TournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
import PredefinedActivities from './views/PredefinedActivities.vue';
const routes = [
{ path: '/register', component: Register },
@@ -25,6 +26,7 @@ const routes = [
{ path: '/schedule', component: ScheduleView},
{ path: '/tournaments', component: TournamentsView },
{ path: '/training-stats', component: TrainingStatsView },
{ path: '/predefined-activities', component: PredefinedActivities },
];
const router = createRouter({

View File

@@ -45,7 +45,7 @@
<button type="submit">Zeiten aktualisieren</button>
</form>
</div>
<div v-if="date !== 'new' && date !== null" style="overflow:hidden">
<div v-if="date !== 'new' && date !== null" style="overflow: visible">
<div class="columns">
<div class="column">
<h3 v-if="showGeneralData">Gruppenverwaltung</h3>
@@ -84,7 +84,7 @@
</div>
</div>
<h3>Trainingsplan</h3>
<div style="overflow: auto;">
<div style="overflow-x: auto; overflow-y: visible; position: relative;">
<table>
<thead>
<tr>
@@ -104,11 +104,11 @@
<td>
<span v-if="item.isTimeblock"><i>Zeitblock</i></span>
<span v-else-if="editingActivityId === item.id">
<div style="display: flex; gap: 5px; align-items: center;">
<div style="display: flex; gap: 5px; align-items: center; position: relative;">
<input
type="text"
:value="item.predefinedActivity ? item.predefinedActivity.name : item.activity"
@input="item.activity = $event.target.value"
v-model="editingActivityText"
@input="onEditInputChangeText(item)"
@keyup.enter="saveActivityEdit(item)"
@keyup.esc="cancelActivityEdit"
ref="activityInput"
@@ -116,10 +116,18 @@
/>
<button @click="saveActivityEdit(item)" class="btn-primary" style="padding: 2px 8px; font-size: 12px;"></button>
<button @click="cancelActivityEdit" class="btn-secondary" style="padding: 2px 8px; font-size: 12px;"></button>
<div v-if="editShowDropdown && editSearchForId === item.id && editSearchResults.length" class="dropdown" style="max-height: 9.5em;">
<div v-for="s in editSearchResults" :key="s.id" @click="chooseEditSuggestion(s, item)">
<span v-if="s.code && s.code.trim() !== ''"><strong>[{{ s.name }}]</strong> </span>
</div>
</div>
</div>
</span>
<span v-else @click="startActivityEdit(item)" class="clickable">
{{ item.predefinedActivity ? item.predefinedActivity.name : item.activity }}
<span v-else @click="startActivityEdit(item)" class="clickable activity-label"
:title="item.predefinedActivity && item.predefinedActivity.name ? item.predefinedActivity.name : ''">
{{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '')
? item.predefinedActivity.code
: (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
</span>
</td>
<td>{{ item.groupActivity ? item.groupActivity.name : '' }}</td>
@@ -134,7 +142,13 @@
<tr>
<td></td>
<td></td>
<td>{{ groupItem.groupPredefinedActivity.name }}</td>
<td>
<span class="activity-label" :title="(groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.name) ? groupItem.groupPredefinedActivity.name : ''">
{{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '')
? groupItem.groupPredefinedActivity.code
: groupItem.groupPredefinedActivity.name }}
</span>
</td>
<td>{{ groupItem.groupsGroupActivity.name }}</td>
<td></td>
<td></td>
@@ -152,8 +166,17 @@
@click="addGroupActivity">Gruppen-Aktivität</button>
</td>
<td v-if="addNewItem || addNewGroupActivity">
<input v-if="addtype === 'activity'" type="text" v-model="newPlanItem.activity"
placeholder="Aktivität / Zeitblock" required />
<div v-if="addtype === 'activity'" style="position: relative;">
<input type="text" v-model="newPlanItem.activity"
placeholder="Aktivität / Zeitblock" required
@input="onNewItemInputChange"
/>
<div v-if="newItemShowDropdown && newItemSearchResults.length" class="dropdown" style="max-height: 9.5em;">
<div v-for="s in newItemSearchResults" :key="s.id" @click="chooseNewItemSuggestion(s)">
<span v-if="s.code && s.code.trim() !== ''"><strong>[{{ s.code }}]</strong> </span>{{ s.name }}
</div>
</div>
</div>
</td>
<td v-else-if="addNewTimeblock">Zeitblock</td>
<td v-if="addNewGroupActivity" colspan="2">
@@ -188,14 +211,16 @@
<div v-if="accidents.length > 0">
</div>
</div>
<h3>Aktivitäten</h3>
<textarea v-model="newActivity"></textarea>
<button @click="addActivity">Aktivität hinzufügen</button>
<ul>
<li v-for="activity in activities" :key="activity.id">
{{ activity.description }}
</li>
</ul>
<h3 class="clickable" @click="toggleActivitiesBox">Aktivitäten <span>{{ showActivitiesBox ? '-' : '+' }}</span></h3>
<div v-if="showActivitiesBox" class="collapsible-box">
<textarea v-model="newActivity"></textarea>
<button @click="addActivity">Aktivität hinzufügen</button>
<ul>
<li v-for="activity in activities" :key="activity.id">
{{ activity.description }}
</li>
</ul>
</div>
<multiselect v-model="selectedActivityTags" :options="availableTags" placeholder="Tags auswählen"
label="name" track-by="id" multiple :close-on-select="true" @tag="addNewTag"
@remove="removeActivityTag" :allow-empty="false" @keydown.enter.prevent="addNewTagFromInput" />
@@ -371,6 +396,16 @@ export default {
},
accidents: [],
editingActivityId: null, // ID der Aktivität, die gerade bearbeitet wird
// Suche für Inline-Edit
editShowDropdown: false,
editSearchResults: [],
editSearchForId: null,
editingActivityText: '',
// Suche für Neue-Item-Eingabe
newItemShowDropdown: false,
newItemSearchResults: [],
// Aktivitäten-Box (rechts)
showActivitiesBox: false,
};
},
watch: {
@@ -1141,12 +1176,13 @@ export default {
this.$nextTick(() => {
this.$refs.activityInput.focus();
});
this.editingActivityText = item.predefinedActivity ? item.predefinedActivity.name : item.activity || '';
},
async saveActivityEdit(item) {
try {
await apiClient.put(`/diary-date-activities/${this.currentClub}/${item.id}`, {
customActivityName: item.activity,
customActivityName: this.editingActivityText,
duration: item.duration,
durationText: item.durationText,
groupId: item.groupId,
@@ -1156,11 +1192,61 @@ export default {
await this.loadTrainingPlan();
this.editingActivityId = null;
this.editingActivityText = '';
} catch (error) {
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
async searchPredefinedActivities(term) {
if (!term || term.trim().length < 2) {
return [];
}
try {
const r = await apiClient.get('/predefined-activities/search/query', { params: { q: term, limit: 10 } });
return r.data || [];
} catch (e) {
return [];
}
},
async onEditInputChangeText(item) {
const term = this.editingActivityText;
this.editSearchForId = item.id;
if (!term || term.trim().length < 2) {
this.editShowDropdown = false;
this.editSearchResults = [];
return;
}
const results = await this.searchPredefinedActivities(term);
this.editSearchResults = results;
this.editShowDropdown = results.length > 0;
},
chooseEditSuggestion(s, item) {
this.editingActivityText = (s.code && s.code.trim() !== '') ? s.code : s.name;
this.editShowDropdown = false;
this.editSearchResults = [];
},
async onNewItemInputChange() {
const term = this.newPlanItem.activity;
if (!term || term.trim().length < 2) {
this.newItemShowDropdown = false;
this.newItemSearchResults = [];
return;
}
const results = await this.searchPredefinedActivities(term);
this.newItemSearchResults = results;
this.newItemShowDropdown = results.length > 0;
},
chooseNewItemSuggestion(s) {
this.newPlanItem.activity = (s.code && s.code.trim() !== '') ? s.code : s.name;
this.newItemShowDropdown = false;
this.newItemSearchResults = [];
},
async loadTrainingPlan() {
try {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
@@ -1173,6 +1259,9 @@ export default {
cancelActivityEdit() {
this.editingActivityId = null;
},
toggleActivitiesBox() {
this.showActivitiesBox = !this.showActivitiesBox;
},
},
async mounted() {
await this.init();
@@ -1222,13 +1311,13 @@ h3 {
display: flex;
justify-content: space-between;
width: calc(100% - 1em);
overflow: hidden;
overflow: visible;
height: 100%;
}
.column:first-child {
flex: 1;
overflow: hidden;
overflow: visible;
height: 100%;
justify-self: start;
display: flex;
@@ -1305,9 +1394,30 @@ li {
width: 100%;
}
.collapsible-box {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.5rem;
background: white;
}
table {
width: 100%;
border-collapse: collapse;
overflow: visible;
}
thead, tbody, tr, td, th {
overflow: visible;
}
td {
position: static;
}
/* Bearbeitungszelle soll relativer Kontext sein */
.clickable, td > div[style*="position: relative"] {
position: relative;
}
th,
@@ -1339,7 +1449,7 @@ input[type="number"] {
overflow-y: auto;
position: absolute;
background-color: white;
z-index: 1000;
z-index: 9999;
width: calc(100% - 20px);
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
max-width: 30em;
@@ -1465,7 +1575,7 @@ img {
.diary {
width: 100%;
height: 100%;
overflow: hidden;
overflow: visible;
display: flex;
flex-direction: column;
}
@@ -1491,4 +1601,33 @@ img {
padding: 3px;
background-color: #fff;
}
.collapsible-box {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.5rem;
background: white;
}
.collapsible-box h3 {
margin-top: 0;
margin-bottom: 10px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.collapsible-box h3 span {
font-size: 1.2em;
transition: transform 0.3s ease;
}
.collapsible-box.collapsed h3 span {
transform: rotate(0deg);
}
.collapsible-box.expanded h3 span {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="predef-activities">
<h2>Vordefinierte Aktivitäten</h2>
<div class="grid">
<div class="list">
<div class="toolbar">
<button @click="startCreate" class="btn-primary">Neu</button>
<button @click="reload" class="btn-secondary">Neu laden</button>
</div>
<ul class="items">
<li v-for="a in activities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
<div class="title">
<strong>{{ a.code ? '[' + a.code + '] ' : '' }}{{ a.name }}</strong>
</div>
<div class="meta">
<span v-if="a.duration">{{ a.duration }} min</span>
<span v-if="a.durationText"> ({{ a.durationText }})</span>
</div>
</li>
</ul>
</div>
<div class="detail" v-if="editModel">
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
<form @submit.prevent="save">
<label>Name
<input type="text" v-model="editModel.name" required />
</label>
<label>Kürzel
<input type="text" v-model="editModel.code" />
</label>
<label>Dauer (Minuten)
<input type="number" v-model.number="editModel.duration" min="0" />
</label>
<label>Dauer (Text)
<input type="text" v-model="editModel.durationText" placeholder="z.B. 2x7" />
</label>
<label>Beschreibung
<textarea v-model="editModel.description" rows="4" />
</label>
<label>Bild-Link (optional)
<input type="text" v-model="editModel.imageLink" placeholder="/api/predefined-activities/:id/image/:imageId oder extern" />
</label>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="btn-secondary" @click="cancel">Abbrechen</button>
</div>
</form>
<div v-if="editModel.id" class="images">
<h4>Bild hochladen</h4>
<input type="file" accept="image/*" @change="onFileChange" />
<button class="btn-secondary" :disabled="!selectedFile" @click="uploadImage">Hochladen</button>
<div class="image-list" v-if="images && images.length">
<div v-for="img in images" :key="img.id" class="image-item">
<img :src="imageUrl(img)" alt="Activity Image" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
export default {
name: 'PredefinedActivities',
data() {
return {
activities: [],
selectedActivity: null,
editModel: null,
images: [],
selectedFile: null,
};
},
methods: {
async reload() {
const r = await apiClient.get('/predefined-activities');
this.activities = r.data;
},
async select(a) {
this.selectedActivity = a;
const r = await apiClient.get(`/predefined-activities/${a.id}`);
const { images, ...activity } = r.data;
this.images = images || [];
this.editModel = { ...activity };
},
startCreate() {
this.selectedActivity = null;
this.images = [];
this.editModel = {
name: '',
code: '',
description: '',
duration: null,
durationText: '',
imageLink: '',
};
},
cancel() {
this.editModel = null;
this.selectedActivity = null;
this.images = [];
},
async save() {
if (!this.editModel) return;
if (this.editModel.id) {
const { id, ...payload } = this.editModel;
const r = await apiClient.put(`/predefined-activities/${id}`, payload);
this.editModel = r.data;
} else {
const r = await apiClient.post('/predefined-activities', this.editModel);
this.editModel = r.data;
}
await this.reload();
},
onFileChange(e) {
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
},
imageUrl(img) {
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
},
async uploadImage() {
if (!this.editModel || !this.editModel.id || !this.selectedFile) return;
const fd = new FormData();
fd.append('image', this.selectedFile);
await apiClient.post(`/predefined-activities/${this.editModel.id}/image`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// Nach Upload Details neu laden
await this.select(this.editModel);
this.selectedFile = null;
}
},
async mounted() {
await this.reload();
}
}
</script>
<style scoped>
.predef-activities {
display: flex;
flex-direction: column;
}
.grid {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1rem;
}
.list {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.5rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.items {
list-style: none;
padding: 0;
margin: 0;
}
.items li {
padding: 0.5rem;
border-radius: var(--border-radius-small);
cursor: pointer;
}
.items li:hover { background: var(--primary-light);
}
.items li.active { background: var(--primary-light); color: var(--primary-color); }
.detail {
background: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 0.75rem;
}
label { display: block; margin-bottom: 0.5rem; }
input[type="text"], input[type="number"], textarea { width: 100%; }
.actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
.image-list { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
.image-item img { max-height: 120px; border: 1px solid var(--border-color); border-radius: var(--border-radius-small); }
</style>