Compare commits
7 Commits
spielplaen
...
activitypa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd96f5df1 | ||
|
|
4bfa6a5889 | ||
|
|
f4187512ba | ||
|
|
b557297bf0 | ||
|
|
eb2273e28c | ||
|
|
091599b745 | ||
|
|
d70a5ca63e |
@@ -5,8 +5,8 @@ import fs from 'fs';
|
||||
|
||||
export const createPredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { name, code, description, durationText, duration, imageLink } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData });
|
||||
res.status(201).json(predefinedActivity);
|
||||
} catch (error) {
|
||||
console.error('[createPredefinedActivity] - Error:', error);
|
||||
@@ -42,8 +42,8 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
export const updatePredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, code, description, durationText, duration, imageLink } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData });
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.error('[updatePredefinedActivity] - Error:', error);
|
||||
|
||||
@@ -33,10 +33,15 @@ export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(filePath);
|
||||
|
||||
// Extrahiere Zeichnungsdaten aus dem Request
|
||||
const drawingData = req.body.drawingData ? JSON.parse(req.body.drawingData) : null;
|
||||
console.log('[uploadPredefinedActivityImage] - drawingData:', drawingData);
|
||||
|
||||
const imageRecord = await PredefinedActivityImage.create({
|
||||
predefinedActivityId: id,
|
||||
imagePath: filePath,
|
||||
mimeType: 'image/jpeg',
|
||||
drawingData: drawingData ? JSON.stringify(drawingData) : null,
|
||||
});
|
||||
|
||||
// Optional: als imageLink am Activity-Datensatz setzen
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Add drawing_data column to predefined_activity_images table
|
||||
-- Date: 2025-09-22
|
||||
-- Description: Adds drawing_data column to store Court Drawing Tool metadata
|
||||
|
||||
ALTER TABLE `predefined_activity_images`
|
||||
ADD COLUMN `drawing_data` TEXT NULL
|
||||
COMMENT 'JSON string containing drawing metadata for Court Drawing Tool'
|
||||
AFTER `mime_type`;
|
||||
|
||||
-- Verify the column was added
|
||||
DESCRIBE `predefined_activity_images`;
|
||||
@@ -19,6 +19,11 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
drawingData: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON string with metadata for Court Drawing Tool'
|
||||
},
|
||||
durationText: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
|
||||
@@ -19,6 +19,11 @@ const PredefinedActivityImage = sequelize.define('PredefinedActivityImage', {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
drawingData: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'JSON string containing drawing metadata for Court Drawing Tool'
|
||||
},
|
||||
}, {
|
||||
tableName: 'predefined_activity_images',
|
||||
timestamps: true,
|
||||
|
||||
@@ -23,6 +23,7 @@ router.get('/', authenticate, getAllPredefinedActivities);
|
||||
router.get('/:id', authenticate, getPredefinedActivityById);
|
||||
router.put('/:id', authenticate, updatePredefinedActivity);
|
||||
router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.put('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.delete('/:id/image/:imageId', authenticate, deletePredefinedActivityImage);
|
||||
router.get('/search/query', authenticate, searchPredefinedActivities);
|
||||
router.post('/merge', authenticate, mergePredefinedActivities);
|
||||
|
||||
@@ -33,11 +33,11 @@ const login = async (email, password) => {
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
throw { status: 401, message: 'Ungültige Anmeldedaten' };
|
||||
}
|
||||
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '3h' });
|
||||
await UserToken.create({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
expiresAt: new Date(Date.now() + 3 * 3600 * 1000),
|
||||
});
|
||||
return { token };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import Group from '../models/Group.js';
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
@@ -12,15 +13,47 @@ class DiaryDateActivityService {
|
||||
await checkAccess(userToken, clubId);
|
||||
console.log('[DiaryDateActivityService::createActivity] - add: ', data);
|
||||
const { activity, ...restData } = data;
|
||||
let predefinedActivity = await PredefinedActivity.findOne({ where: { name: data.activity } });
|
||||
// Versuche, die PredefinedActivity robust zu finden:
|
||||
// 1) per übergebener ID
|
||||
// 2) per Name ODER Code (das Feld "activity" kann Kürzel oder Name sein)
|
||||
// 3) erst dann neu anlegen
|
||||
let predefinedActivity = null;
|
||||
|
||||
if (data.predefinedActivityId) {
|
||||
predefinedActivity = await PredefinedActivity.findByPk(data.predefinedActivityId);
|
||||
}
|
||||
|
||||
if (!predefinedActivity) {
|
||||
const normalized = (data.activity || '').trim();
|
||||
if (normalized) {
|
||||
predefinedActivity = await PredefinedActivity.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ name: normalized },
|
||||
{ code: normalized }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!predefinedActivity) {
|
||||
predefinedActivity = await PredefinedActivity.create({
|
||||
name: data.activity,
|
||||
description: '',
|
||||
duration: data.duration
|
||||
name: data.name || data.activity || '',
|
||||
code: data.code || (data.activity || ''),
|
||||
description: data.description || '',
|
||||
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : null
|
||||
});
|
||||
}
|
||||
restData.predefinedActivityId = predefinedActivity.id;
|
||||
|
||||
// Bereinige duration-Feld für DiaryDateActivity
|
||||
if (restData.duration === '' || restData.duration === undefined) {
|
||||
restData.duration = null;
|
||||
} else if (typeof restData.duration === 'string') {
|
||||
restData.duration = parseInt(restData.duration);
|
||||
}
|
||||
|
||||
const maxOrderId = await DiaryDateActivity.max('orderId', {
|
||||
where: { diaryDateId: data.diaryDateId }
|
||||
});
|
||||
@@ -54,8 +87,8 @@ class DiaryDateActivityService {
|
||||
console.log('[DiaryDateActivityService::updateActivity] - creating new PredefinedActivity');
|
||||
predefinedActivity = await PredefinedActivity.create({
|
||||
name: data.customActivityName,
|
||||
description: '',
|
||||
duration: data.duration || activity.duration
|
||||
description: data.description || '',
|
||||
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : (activity.duration || null)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,9 +164,7 @@ class DiaryDateActivityService {
|
||||
}
|
||||
|
||||
async getActivities(userToken, clubId, diaryDateId) {
|
||||
console.log('[DiaryDateActivityService::getActivities] - check user access');
|
||||
await checkAccess(userToken, clubId);
|
||||
console.log(`[DiaryDateActivityService::getActivities] - fetch activities for diaryDateId: ${diaryDateId}`);
|
||||
const activities = await DiaryDateActivity.findAll({
|
||||
where: { diaryDateId },
|
||||
order: [['orderId', 'ASC']],
|
||||
@@ -141,6 +172,12 @@ class DiaryDateActivityService {
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity',
|
||||
include: [
|
||||
{
|
||||
model: PredefinedActivityImage,
|
||||
as: 'images'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
@@ -158,8 +195,64 @@ class DiaryDateActivityService {
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log(`[DiaryDateActivityService::getActivities] - found ${activities.length} activities`);
|
||||
return activities;
|
||||
|
||||
// Füge imageUrl zu jeder PredefinedActivity hinzu
|
||||
const activitiesWithImages = await Promise.all(activities.map(async activity => {
|
||||
// Konvertiere zu JSON und zurück, um alle Eigenschaften zu serialisieren
|
||||
const activityData = activity.toJSON();
|
||||
|
||||
if (activityData.predefinedActivity) {
|
||||
// Hole die erste verfügbare Image-ID direkt aus der Datenbank
|
||||
const allImages = await PredefinedActivityImage.findAll({
|
||||
where: { predefinedActivityId: activityData.predefinedActivity.id },
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
|
||||
const firstImage = allImages.length > 0 ? allImages[0] : null;
|
||||
|
||||
// Füge Zeichnungsdaten hinzu, falls vorhanden
|
||||
if (firstImage && firstImage.drawingData) {
|
||||
try {
|
||||
activityData.predefinedActivity.drawingData = JSON.parse(firstImage.drawingData);
|
||||
} catch (error) {
|
||||
console.error(`Activity ${activityData.predefinedActivity.id}: Error parsing drawingData:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (firstImage) {
|
||||
// Füge sowohl imageUrl als auch imageLink mit Image-ID hinzu
|
||||
activityData.predefinedActivity.imageUrl = `/api/predefined-activities/${activityData.predefinedActivity.id}/image/${firstImage.id}`;
|
||||
activityData.predefinedActivity.imageLink = `/api/predefined-activities/${activityData.predefinedActivity.id}/image/${firstImage.id}`;
|
||||
} else {
|
||||
// Fallback: Verwende den Basis-Pfad ohne Image-ID
|
||||
activityData.predefinedActivity.imageUrl = `/api/predefined-activities/${activityData.predefinedActivity.id}/image`;
|
||||
activityData.predefinedActivity.imageLink = `/api/predefined-activities/${activityData.predefinedActivity.id}/image`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auch für GroupActivities
|
||||
if (activityData.groupActivities && activityData.groupActivities.length > 0) {
|
||||
for (const groupActivity of activityData.groupActivities) {
|
||||
if (groupActivity.groupPredefinedActivity) {
|
||||
// Hole die erste verfügbare Image-ID direkt aus der Datenbank
|
||||
const firstImage = await PredefinedActivityImage.findOne({
|
||||
where: { predefinedActivityId: groupActivity.groupPredefinedActivity.id },
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
|
||||
if (firstImage) {
|
||||
groupActivity.groupPredefinedActivity.imageUrl = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image/${firstImage.id}`;
|
||||
groupActivity.groupPredefinedActivity.imageLink = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image/${firstImage.id}`;
|
||||
} else {
|
||||
groupActivity.groupPredefinedActivity.imageUrl = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image`;
|
||||
groupActivity.groupPredefinedActivity.imageLink = `/api/predefined-activities/${groupActivity.groupPredefinedActivity.id}/image`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return activityData;
|
||||
}));
|
||||
return activitiesWithImages;
|
||||
}
|
||||
|
||||
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity) {
|
||||
|
||||
@@ -15,6 +15,7 @@ class PredefinedActivityService {
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ class PredefinedActivityService {
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
442
frontend/src/components/CourtDrawingRender.vue
Normal file
442
frontend/src/components/CourtDrawingRender.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="render-container">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
:width="config.canvas.width"
|
||||
:height="config.canvas.height"
|
||||
style="margin:0 auto;"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CourtDrawingRender',
|
||||
props: {
|
||||
drawingData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 600
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 400
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
config: {
|
||||
// Mehr Platz links für die drei Startkreise (Rand außerhalb des Tisches)
|
||||
canvas: { width: 640, height: 400 },
|
||||
table: {
|
||||
// Tabelle etwas schmaler und weiter rechts platzieren
|
||||
width: 500,
|
||||
height: 300,
|
||||
color: '#2d5a2d',
|
||||
borderWidth: 2,
|
||||
borderColor: '#000000',
|
||||
outerFrameWidth: 5,
|
||||
outerFrameColor: '#ffffff',
|
||||
outerFrameBorderWidth: 0.5,
|
||||
outerFrameBorderColor: '#000000'
|
||||
},
|
||||
horizontalLine: { color: '#cfd8dc', width: 2, gap: 10, edgeMargin: 10 },
|
||||
net: { color: '#ffffff', width: 4, overhang: 14, borderColor: '#000000', borderWidth: 0.5 },
|
||||
serviceLine: { color: 'rgba(255,255,255,0.0)', width: 0 },
|
||||
startCircles: {
|
||||
radius: 10,
|
||||
// Abstand vom linken Canvas-Rand (größer = weiter links, da wir von tableX abziehen)
|
||||
x: 20,
|
||||
topYOffset: 35,
|
||||
bottomYOffset: 35,
|
||||
selectedColor: '#ff4444',
|
||||
unselectedColor: '#9e9e9e',
|
||||
selectedBorderColor: '#ffffff',
|
||||
unselectedBorderColor: '#555555',
|
||||
selectedBorderWidth: 2,
|
||||
unselectedBorderWidth: 1
|
||||
},
|
||||
arrows: {
|
||||
primaryColor: '#d32f2f', // rechts -> target (rot)
|
||||
secondaryColor: '#1565c0', // zurück (blau)
|
||||
width: 6,
|
||||
headLength: 24,
|
||||
vhOffsetX: 5,
|
||||
vhOffsetY: 8,
|
||||
rhOffsetX: 10,
|
||||
rhOffsetY: 0
|
||||
},
|
||||
targetCircles: {
|
||||
radius: 13,
|
||||
rightXOffset: 20,
|
||||
middleXOffset: 40,
|
||||
topYOffset: 45,
|
||||
bottomYOffset: 45,
|
||||
transparency: 0.25
|
||||
},
|
||||
leftTargetCircles: {
|
||||
radius: 10,
|
||||
leftXOffset: 20,
|
||||
rightXOffset: 40,
|
||||
topYOffset: 40,
|
||||
bottomYOffset: 40
|
||||
},
|
||||
hitMarker: {
|
||||
radius: 10.5,
|
||||
fill: '#ffffff',
|
||||
stroke: '#000000',
|
||||
lineWidth: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
drawingData: {
|
||||
handler() {
|
||||
this.redraw();
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// apply custom dimensions if provided
|
||||
this.config.canvas.width = this.width || 600;
|
||||
this.config.canvas.height = this.height || 400;
|
||||
this.$nextTick(() => {
|
||||
console.log('CourtDrawingRender: mounted with drawingData =', this.drawingData);
|
||||
this.init();
|
||||
this.redraw();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.canvas = this.$refs.canvas;
|
||||
if (this.canvas) {
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
console.log('CourtDrawingRender: canvas/context initialized');
|
||||
}
|
||||
},
|
||||
redraw() {
|
||||
console.log('CourtDrawingRender: redraw called with data =', this.drawingData);
|
||||
if (!this.ctx) return;
|
||||
const { width, height } = this.config.canvas;
|
||||
// clear and background
|
||||
this.ctx.clearRect(0, 0, width, height);
|
||||
this.ctx.fillStyle = '#f0f0f0';
|
||||
this.ctx.fillRect(0, 0, width, height);
|
||||
this.drawTable();
|
||||
this.drawStartCircles();
|
||||
this.drawArrowsAndLabels();
|
||||
},
|
||||
drawTable() {
|
||||
const ctx = this.ctx;
|
||||
const { table, horizontalLine, net, outerFrameWidth } = {
|
||||
...this.config,
|
||||
outerFrameWidth: this.config.table.outerFrameWidth
|
||||
};
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
|
||||
// table fill
|
||||
ctx.fillStyle = table.color;
|
||||
ctx.fillRect(tableX, tableY, tableWidth, tableHeight);
|
||||
|
||||
// table stroke
|
||||
ctx.strokeStyle = table.borderColor;
|
||||
ctx.lineWidth = table.borderWidth;
|
||||
ctx.strokeRect(tableX, tableY, tableWidth, tableHeight);
|
||||
|
||||
// outer white frame
|
||||
ctx.strokeStyle = table.outerFrameColor;
|
||||
ctx.lineWidth = table.outerFrameWidth;
|
||||
ctx.strokeRect(
|
||||
tableX - table.outerFrameWidth,
|
||||
tableY - table.outerFrameWidth,
|
||||
tableWidth + table.outerFrameWidth * 2,
|
||||
tableHeight + table.outerFrameWidth * 2
|
||||
);
|
||||
|
||||
// black thin frame around white
|
||||
ctx.strokeStyle = table.outerFrameBorderColor;
|
||||
ctx.lineWidth = table.outerFrameBorderWidth;
|
||||
ctx.strokeRect(
|
||||
tableX - table.outerFrameWidth - table.outerFrameBorderWidth,
|
||||
tableY - table.outerFrameWidth - table.outerFrameBorderWidth,
|
||||
tableWidth + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2,
|
||||
tableHeight + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2
|
||||
);
|
||||
|
||||
// horizontal split line (two segments)
|
||||
ctx.strokeStyle = horizontalLine.color;
|
||||
ctx.lineWidth = horizontalLine.width;
|
||||
const centerY = tableY + tableHeight / 2;
|
||||
const centerX = tableX + tableWidth / 2;
|
||||
const gap = horizontalLine.gap;
|
||||
const edge = horizontalLine.edgeMargin;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tableX + edge, centerY);
|
||||
ctx.lineTo(centerX - gap, centerY);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX + gap, centerY);
|
||||
ctx.lineTo(tableX + tableWidth - edge, centerY);
|
||||
ctx.stroke();
|
||||
|
||||
// net vertical
|
||||
ctx.strokeStyle = net.color;
|
||||
ctx.lineWidth = net.width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, tableY - net.overhang);
|
||||
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = net.borderColor;
|
||||
ctx.lineWidth = net.borderWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, tableY - net.overhang);
|
||||
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
|
||||
ctx.stroke();
|
||||
},
|
||||
drawStartCircles() {
|
||||
const ctx = this.ctx;
|
||||
const cfg = this.config.startCircles;
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
|
||||
// Startkreis links VOR dem Tisch positionieren – nur den Kreis zeichnen,
|
||||
// von dem auch der rote Pfeil startet
|
||||
const circleX = tableX - cfg.x; // Links vom Tisch
|
||||
const topY = tableY + cfg.topYOffset;
|
||||
const midY = tableY + tableHeight / 2;
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
|
||||
// Mapping und Fallback: bei "AS" auf 'middle'
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
|
||||
ctx.fillStyle = cfg.selectedColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(circleX, y, cfg.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = cfg.selectedBorderColor;
|
||||
ctx.lineWidth = cfg.selectedBorderWidth;
|
||||
ctx.stroke();
|
||||
},
|
||||
computeRightTargetPosition(number) {
|
||||
// Geometrie wie im Tool: nutzt rightXOffset/middleXOffset
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const cfg = this.config.targetCircles;
|
||||
|
||||
const x1 = tableX + tableWidth - cfg.rightXOffset; // (1,2,3)
|
||||
const x3 = tableX + tableWidth / 2 + cfg.middleXOffset; // (7,8,9)
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff / 2; // (4,5,6)
|
||||
|
||||
const positions = {
|
||||
1: { x: x1, y: tableY + cfg.topYOffset },
|
||||
2: { x: x1, y: tableY + tableHeight / 2 },
|
||||
3: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
4: { x: x2, y: tableY + cfg.topYOffset },
|
||||
5: { x: x2, y: tableY + tableHeight / 2 },
|
||||
6: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
7: { x: x3, y: tableY + cfg.topYOffset },
|
||||
8: { x: x3, y: tableY + tableHeight / 2 },
|
||||
9: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset }
|
||||
};
|
||||
return positions[number] || null;
|
||||
},
|
||||
getStartPoint() {
|
||||
// Startpunkt wie im Tool abhängig von Schlagseite (VH/RH)
|
||||
const sc = this.config.startCircles;
|
||||
const ar = this.config.arrows;
|
||||
const tblW = this.config.table.width;
|
||||
const tblH = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tblW) / 2;
|
||||
const tableY = (this.config.canvas.height - tblH) / 2;
|
||||
const circleX = tableX - sc.x; // Kreis links vor dem Tisch
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const pos = map[this.drawingData?.selectedStartPosition] || 'middle';
|
||||
const y = pos === 'top' ? tableY + sc.topYOffset : pos === 'bottom' ? tableY + tblH - sc.bottomYOffset : tableY + tblH / 2;
|
||||
const isVH = (this.drawingData?.strokeType || 'VH') === 'VH';
|
||||
const startX = isVH
|
||||
? circleX + sc.radius + ar.vhOffsetX
|
||||
: circleX + sc.radius + ar.rhOffsetX;
|
||||
// VH: unterhalb seitlich (circleRadius + vhOffsetY), RH: rechts (rhOffsetY = 0)
|
||||
const startYOffset = isVH ? (sc.radius + ar.vhOffsetY) : ar.rhOffsetY;
|
||||
return { x: startX, y: y + startYOffset };
|
||||
},
|
||||
drawArrow(ctx, from, to, color, label) {
|
||||
const { width, headLength } = this.config.arrows;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
// line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
// arrow head
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
},
|
||||
drawLabelBelow(ctx, text, anchor) {
|
||||
if (!text) return;
|
||||
ctx.font = 'bold 13px Helvetica';
|
||||
const padding = 6;
|
||||
const yOffset = 23; // 5px tiefer
|
||||
const metrics = ctx.measureText(text);
|
||||
const textWidth = metrics.width;
|
||||
let x = anchor.x - textWidth / 2; // zentriert unter dem Punkt
|
||||
let y = anchor.y + yOffset;
|
||||
// clamp innerhalb Canvas
|
||||
x = Math.max(4, Math.min(this.config.canvas.width - textWidth - 4, x));
|
||||
y = Math.max(14, Math.min(this.config.canvas.height - 4, y));
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeText(text, x, y);
|
||||
ctx.fillText(text, x, y);
|
||||
},
|
||||
drawArrowsAndLabels() {
|
||||
if (!this.drawingData) return;
|
||||
const ctx = this.ctx;
|
||||
const from = this.getStartPoint();
|
||||
|
||||
// First arrow: to right target
|
||||
const tp = Number(this.drawingData.targetPosition);
|
||||
if (tp) {
|
||||
const to = this.computeRightTargetPosition(tp);
|
||||
// Zielmarkierung (unter dem Pfeilkopf)
|
||||
this.drawHitMarker(ctx, to);
|
||||
const strokeSide = this.drawingData.strokeType || '';
|
||||
const spinAbbrev = this.abbrevSpin(this.drawingData.spinType);
|
||||
// Text gehört an die Quelle (ohne "target")
|
||||
const sourceLabel = `${strokeSide} ${spinAbbrev}`.trim();
|
||||
const toEnd = { x: to.x - this.config.targetCircles.radius, y: to.y };
|
||||
this.drawArrow(ctx, from, toEnd, this.config.arrows.primaryColor);
|
||||
// Unter dem Startkreis beschriften
|
||||
const startCenter = this.getStartCircleCenter();
|
||||
this.drawLabelBelow(ctx, sourceLabel, startCenter);
|
||||
}
|
||||
|
||||
// Second arrow (optional): from right source to left target
|
||||
const leftTarget = this.drawingData.nextStrokeTargetPosition ? Number(this.drawingData.nextStrokeTargetPosition) : null;
|
||||
if (tp && leftTarget) {
|
||||
// source near previous right target
|
||||
const sourceRightCenter = this.computeRightTargetPosition(tp);
|
||||
// left target mapping: mirror scheme to left half
|
||||
const toLeftCenter = this.computeLeftTargetPosition(leftTarget);
|
||||
// Zielmarkierung links
|
||||
this.drawHitMarker(ctx, toLeftCenter);
|
||||
const side = this.drawingData.nextStrokeSide || '';
|
||||
const type = this.drawingData.nextStrokeType || '';
|
||||
// Text gehört ans Ziel (ohne "extra target")
|
||||
const targetLabel = `${side} ${type}`.trim();
|
||||
const sourceRight = { x: sourceRightCenter.x - this.config.targetCircles.radius, y: sourceRightCenter.y };
|
||||
const toLeft = { x: toLeftCenter.x + this.config.leftTargetCircles.radius, y: toLeftCenter.y };
|
||||
this.drawArrow(ctx, sourceRight, toLeft, this.config.arrows.secondaryColor);
|
||||
// Unter dem rechten Ziel (target der ersten Linie) beschriften
|
||||
this.drawLabelBelow(ctx, targetLabel, sourceRightCenter);
|
||||
}
|
||||
},
|
||||
getStartCircleCenter() {
|
||||
const cfg = this.config.startCircles;
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const circleX = tableX - cfg.x;
|
||||
const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' };
|
||||
const selKey = this.drawingData?.selectedStartPosition || 'AS';
|
||||
const selectedPos = map[selKey] || 'middle';
|
||||
const topY = tableY + cfg.topYOffset;
|
||||
const midY = tableY + tableHeight / 2;
|
||||
const botY = tableY + tableHeight - cfg.bottomYOffset;
|
||||
const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
|
||||
return { x: circleX, y };
|
||||
},
|
||||
drawHitMarker(ctx, pos) {
|
||||
if (!pos) return;
|
||||
const mk = this.config.hitMarker;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, mk.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = mk.fill;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = mk.lineWidth;
|
||||
ctx.strokeStyle = mk.stroke;
|
||||
ctx.stroke();
|
||||
},
|
||||
computeLeftTargetPosition(number) {
|
||||
// Spiegelung wie im Tool: nutzt leftTargetCircles Offsets
|
||||
const tableWidth = this.config.table.width;
|
||||
const tableHeight = this.config.table.height;
|
||||
const tableX = (this.config.canvas.width - tableWidth) / 2;
|
||||
const tableY = (this.config.canvas.height - tableHeight) / 2;
|
||||
const cfg = this.config.leftTargetCircles;
|
||||
|
||||
const x1 = tableX + cfg.leftXOffset; // linke Spalte (Lang)
|
||||
const x3 = tableX + tableWidth / 2 - cfg.rightXOffset; // nah am Netz (Kurz)
|
||||
const xdiff = x3 - x1;
|
||||
const x2 = x3 - xdiff / 2;
|
||||
|
||||
// Gespiegelte Y-Zuordnung wie im Tool:
|
||||
// 1,4,7 = unten (VH gespiegelt)
|
||||
// 2,5,8 = mitte
|
||||
// 3,6,9 = oben (RH gespiegelt)
|
||||
const positions = {
|
||||
1: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
2: { x: x1, y: tableY + tableHeight / 2 },
|
||||
3: { x: x1, y: tableY + cfg.topYOffset },
|
||||
4: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
5: { x: x2, y: tableY + tableHeight / 2 },
|
||||
6: { x: x2, y: tableY + cfg.topYOffset },
|
||||
7: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset },
|
||||
8: { x: x3, y: tableY + tableHeight / 2 },
|
||||
9: { x: x3, y: tableY + cfg.topYOffset }
|
||||
};
|
||||
return positions[number] || null;
|
||||
},
|
||||
abbrevSpin(spin) {
|
||||
if (!spin) return '';
|
||||
const map = {
|
||||
Unterschnitt: 'US',
|
||||
Überschnitt: 'OS',
|
||||
Seitschnitt: 'SR',
|
||||
Seitunterschnitt: 'SU',
|
||||
Gegenläufer: 'GL'
|
||||
};
|
||||
return map[spin] || spin;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.render-container {
|
||||
width: 100%;
|
||||
}
|
||||
canvas { display: block; max-width: 100%; height: auto; }
|
||||
</style>
|
||||
|
||||
|
||||
1618
frontend/src/components/CourtDrawingTool.vue
Normal file
1618
frontend/src/components/CourtDrawingTool.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -126,10 +126,11 @@
|
||||
</span>
|
||||
<span v-else @click="startActivityEdit(item)" class="clickable activity-label"
|
||||
:title="item.predefinedActivity && item.predefinedActivity.name ? item.predefinedActivity.name : ''">
|
||||
<span v-if="item.predefinedActivity && item.predefinedActivity.imageLink"
|
||||
@click.stop="showActivityImage(item.predefinedActivity.imageLink)"
|
||||
class="image-icon"
|
||||
title="Bild anzeigen">🖼️</span>
|
||||
<!-- Icon öffnet Rendering (falls vorhanden) oder Bild im Modal -->
|
||||
<span v-if="item.predefinedActivity"
|
||||
@click.stop="openActivityVisual(item.predefinedActivity)"
|
||||
class="image-icon"
|
||||
title="Bild/Zeichnung anzeigen">🖼️</span>
|
||||
{{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '')
|
||||
? item.predefinedActivity.code
|
||||
: (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
|
||||
@@ -161,10 +162,12 @@
|
||||
<td></td>
|
||||
<td>
|
||||
<span class="activity-label" :title="(groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.name) ? groupItem.groupPredefinedActivity.name : ''">
|
||||
<span v-if="groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.imageLink"
|
||||
@click.stop="showActivityImage(groupItem.groupPredefinedActivity.imageLink)"
|
||||
class="image-icon"
|
||||
title="Bild anzeigen">🖼️</span>
|
||||
<!-- Icon öffnet Rendering (falls vorhanden) oder Bild im Modal -->
|
||||
<span v-if="groupItem.groupPredefinedActivity"
|
||||
@click.stop="openActivityVisual(groupItem.groupPredefinedActivity)"
|
||||
class="image-icon"
|
||||
title="Bild/Zeichnung anzeigen">🖼️</span>
|
||||
|
||||
{{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '')
|
||||
? groupItem.groupPredefinedActivity.code
|
||||
: groupItem.groupPredefinedActivity.name }}
|
||||
@@ -319,8 +322,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showImage" class="memberImage">
|
||||
<img :src="imageUrl" @click="closeImage" />
|
||||
<div v-if="showImage || showRenderModal" class="memberImage" @click="closeImage">
|
||||
<template v-if="showImage">
|
||||
<img :src="imageUrl" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<CourtDrawingRender :drawing-data="renderModalData" :width="560" :height="360" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="showAccidentForm" class="accidentForm">
|
||||
<form @submit.prevent="submitAccident">
|
||||
@@ -345,7 +353,7 @@
|
||||
+ accident.accident}}</li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schnell hinzufügen Dialog -->
|
||||
<div v-if="showQuickAddDialog" class="modal-overlay" @click.self="closeQuickAddDialog">
|
||||
@@ -397,10 +405,11 @@ import apiClient from '../apiClient.js';
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import Sortable from 'sortablejs';
|
||||
import PDFGenerator from '../components/PDFGenerator.js';
|
||||
import CourtDrawingRender from '../components/CourtDrawingRender.vue';
|
||||
|
||||
export default {
|
||||
name: 'DiaryView',
|
||||
components: { Multiselect },
|
||||
components: { Multiselect, CourtDrawingRender },
|
||||
data() {
|
||||
return {
|
||||
date: null,
|
||||
@@ -436,6 +445,8 @@ export default {
|
||||
showDropdown: false,
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
showRenderModal: false,
|
||||
renderModalData: null,
|
||||
groups: [],
|
||||
currentTimeBlockId: null,
|
||||
newGroupName: '',
|
||||
@@ -526,6 +537,36 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
drawingDataFor(pa) {
|
||||
// Zeichnungsdaten können bereits als Objekt vorliegen oder als JSON-String
|
||||
try {
|
||||
if (!pa) return null;
|
||||
if (pa.drawingData && typeof pa.drawingData === 'object') {
|
||||
console.debug('DiaryView: drawingData (object) gefunden für', pa.id);
|
||||
return pa.drawingData;
|
||||
}
|
||||
if (pa.drawingData && typeof pa.drawingData === 'string') {
|
||||
const parsed = JSON.parse(pa.drawingData);
|
||||
console.debug('DiaryView: drawingData (string→parsed) für', pa.id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
// Fallback: falls über images[0].drawingData geliefert wurde
|
||||
if (pa.images && pa.images.length) {
|
||||
// Nimm das erste Bild, das drawingData enthält
|
||||
const withData = pa.images.find(img => !!img.drawingData);
|
||||
if (withData) {
|
||||
const parsedImg = typeof withData.drawingData === 'string'
|
||||
? JSON.parse(withData.drawingData)
|
||||
: withData.drawingData;
|
||||
console.debug('DiaryView: drawingData aus images für', pa.id, 'imageId=', withData.id, parsedImg);
|
||||
return parsedImg;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('DiaryView: drawingData parse error:', e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async init() {
|
||||
if (this.isAuthenticated && this.currentClub) {
|
||||
const response = await apiClient.get(`/diary/${this.currentClub}`);
|
||||
@@ -1119,12 +1160,43 @@ export default {
|
||||
},
|
||||
closeImage() {
|
||||
this.showImage = false;
|
||||
this.showRenderModal = false;
|
||||
this.renderModalData = null;
|
||||
this.imageUrl = '';
|
||||
},
|
||||
|
||||
showActivityImage(imageLink) {
|
||||
this.imageUrl = imageLink;
|
||||
this.showImage = true;
|
||||
async showActivityImage(imageLink) {
|
||||
try {
|
||||
// Vorherige Object-URL freigeben
|
||||
if (this.imageUrl && this.imageUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.imageUrl);
|
||||
}
|
||||
|
||||
if (imageLink && imageLink.startsWith('/api/')) {
|
||||
// Über API mit Auth und als Blob laden → vermeidet ORB
|
||||
const path = imageLink.replace(/^\/api\//, '');
|
||||
const resp = await apiClient.get(`/${path}`, { responseType: 'blob' });
|
||||
this.imageUrl = URL.createObjectURL(resp.data);
|
||||
} else {
|
||||
this.imageUrl = imageLink;
|
||||
}
|
||||
this.showImage = true;
|
||||
} catch (e) {
|
||||
console.error('Bild laden fehlgeschlagen:', e);
|
||||
alert('Bild konnte nicht geladen werden.');
|
||||
}
|
||||
},
|
||||
async openActivityVisual(pa) {
|
||||
// Bevorzugt Rendering, ansonsten Bild
|
||||
const data = this.drawingDataFor(pa);
|
||||
if (data) {
|
||||
// Erzeuge temporär ein PNG im Client: Canvas offscreen rendern über Renderer-Komponente ist aufwändig.
|
||||
// Einfacher: öffne ein kleines Modal mit der Renderer-Komponente statt imageUrl.
|
||||
this.renderModalData = data;
|
||||
this.showRenderModal = true;
|
||||
} else if (pa.imageLink) {
|
||||
await this.showActivityImage(pa.imageLink);
|
||||
}
|
||||
},
|
||||
async loadMemberImage(member) {
|
||||
try {
|
||||
@@ -1910,6 +1982,12 @@ img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.memberImage > div, .memberImage canvas {
|
||||
/* falls Komponenten-Inhalt (Renderer) da ist, mittig zeigen */
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</label>
|
||||
<div class="image-section">
|
||||
<h4>Bild hinzufügen</h4>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben oder ein Bild hochladen:</p>
|
||||
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
|
||||
|
||||
<label>Bild-Link (optional)
|
||||
<input type="text" v-model="editModel.imageLink" placeholder="z.B. https://example.com/bild.jpg oder /api/predefined-activities/:id/image/:imageId" />
|
||||
@@ -74,6 +74,18 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Zeichen-Tool -->
|
||||
<div class="drawing-section">
|
||||
<h5>Übungszeichnung erstellen</h5>
|
||||
<CourtDrawingTool
|
||||
:activity-id="editModel.id"
|
||||
:drawing-data="editModel.drawingData"
|
||||
:allow-image-upload="false"
|
||||
@update-fields="onUpdateFields"
|
||||
@update-drawing-data="onUpdateDrawingData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-list" v-if="images && images.length">
|
||||
<h5>Hochgeladene Bilder:</h5>
|
||||
<div class="image-grid">
|
||||
@@ -97,9 +109,13 @@
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
|
||||
|
||||
export default {
|
||||
name: 'PredefinedActivities',
|
||||
components: {
|
||||
CourtDrawingTool
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
@@ -107,6 +123,7 @@ export default {
|
||||
editModel: null,
|
||||
images: [],
|
||||
selectedFile: null,
|
||||
selectedDrawingData: null,
|
||||
mergeSourceId: '',
|
||||
mergeTargetId: '',
|
||||
};
|
||||
@@ -131,6 +148,15 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
parseDrawingData(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try { return JSON.parse(value); } catch (e) { return null; }
|
||||
},
|
||||
normalizeActivity(activity, images) {
|
||||
const drawingData = this.parseDrawingData(activity && activity.drawingData);
|
||||
return { ...(activity || {}), drawingData };
|
||||
},
|
||||
async reload() {
|
||||
const r = await apiClient.get('/predefined-activities');
|
||||
this.activities = r.data || [];
|
||||
@@ -140,7 +166,24 @@ export default {
|
||||
const r = await apiClient.get(`/predefined-activities/${a.id}`);
|
||||
const { images, ...activity } = r.data;
|
||||
this.images = images || [];
|
||||
this.editModel = { ...activity };
|
||||
// Server-Daten normalisieren und ggf. image-drawingData fallbacken
|
||||
let model = this.normalizeActivity(activity, images);
|
||||
if (!model.drawingData && images && images.length > 0 && images[0].drawingData) {
|
||||
model.drawingData = this.parseDrawingData(images[0].drawingData);
|
||||
}
|
||||
this.editModel = model;
|
||||
},
|
||||
async reloadImages() {
|
||||
if (this.editModel && this.editModel.id) {
|
||||
try {
|
||||
const r = await apiClient.get(`/predefined-activities/${this.editModel.id}`);
|
||||
const { images } = r.data;
|
||||
this.images = images || [];
|
||||
console.log('Images reloaded:', this.images);
|
||||
} catch (error) {
|
||||
console.error('Error reloading images:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
formatItem(a) {
|
||||
return `${a.code ? '[' + a.code + '] ' : ''}${a.name}`;
|
||||
@@ -164,6 +207,7 @@ export default {
|
||||
duration: null,
|
||||
durationText: '',
|
||||
imageLink: '',
|
||||
drawingData: '',
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
@@ -173,36 +217,81 @@ export default {
|
||||
},
|
||||
async save() {
|
||||
if (!this.editModel) return;
|
||||
|
||||
console.log('Save: selectedFile =', this.selectedFile);
|
||||
|
||||
if (this.editModel.id) {
|
||||
const { id, ...payload } = this.editModel;
|
||||
if (payload.drawingData && typeof payload.drawingData === 'object') {
|
||||
payload.drawingData = payload.drawingData;
|
||||
}
|
||||
const r = await apiClient.put(`/predefined-activities/${id}`, payload);
|
||||
this.editModel = r.data;
|
||||
this.editModel = this.normalizeActivity(r.data);
|
||||
} else {
|
||||
const r = await apiClient.post('/predefined-activities', this.editModel);
|
||||
this.editModel = r.data;
|
||||
// Nach dem Erstellen einer neuen Aktivität, falls ein Bild ausgewählt wurde, hochladen
|
||||
if (this.selectedFile) {
|
||||
await this.uploadImage();
|
||||
}
|
||||
this.editModel = this.normalizeActivity(r.data);
|
||||
}
|
||||
|
||||
// Nach dem Speichern (sowohl CREATE als auch UPDATE): Bild hochladen falls vorhanden
|
||||
if (this.selectedFile) {
|
||||
console.log('Uploading image after save...');
|
||||
await this.uploadImage();
|
||||
} else {
|
||||
console.log('No selectedFile to upload');
|
||||
}
|
||||
|
||||
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}`;
|
||||
return `http://localhost:3000/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
|
||||
},
|
||||
async uploadImage() {
|
||||
if (!this.editModel || !this.editModel.id || !this.selectedFile) return;
|
||||
if (!this.editModel || !this.editModel.id || !this.selectedFile) {
|
||||
console.log('Upload skipped: editModel=', this.editModel, 'selectedFile=', this.selectedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting image upload...');
|
||||
console.log('editModel:', this.editModel);
|
||||
console.log('selectedActivity:', this.selectedActivity);
|
||||
console.log('Activity ID (editModel.id):', this.editModel.id);
|
||||
console.log('Activity ID (selectedActivity.id):', this.selectedActivity?.id);
|
||||
console.log('File:', this.selectedFile);
|
||||
|
||||
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;
|
||||
|
||||
// Füge Zeichnungsdaten hinzu, falls vorhanden
|
||||
if (this.selectedDrawingData) {
|
||||
fd.append('drawingData', JSON.stringify(this.selectedDrawingData));
|
||||
console.log('Added drawingData to FormData:', this.selectedDrawingData);
|
||||
}
|
||||
|
||||
// Verwende PUT für Updates, POST für neue Activities
|
||||
const isUpdate = this.selectedActivity && this.selectedActivity.id === this.editModel.id;
|
||||
const method = isUpdate ? 'put' : 'post';
|
||||
|
||||
console.log('Using method:', method);
|
||||
|
||||
try {
|
||||
const response = await apiClient[method](`/predefined-activities/${this.editModel.id}/image`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
console.log('Upload successful:', response);
|
||||
|
||||
// Nach Upload Details neu laden
|
||||
await this.select(this.editModel);
|
||||
this.selectedFile = null;
|
||||
|
||||
// Bildliste explizit aktualisieren
|
||||
await this.reloadImages();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteImage(imageId) {
|
||||
if (!this.editModel || !this.editModel.id) return;
|
||||
@@ -220,6 +309,53 @@ export default {
|
||||
if (!confirm('Alle Aktivitäten mit identischem Namen werden zusammengeführt. Fortfahren?')) return;
|
||||
await apiClient.post('/predefined-activities/deduplicate', {});
|
||||
await this.reload();
|
||||
},
|
||||
onDrawingSave(drawingData) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = drawingData;
|
||||
// Automatisch als Bild-Link setzen, wenn es eine Zeichnung gibt
|
||||
if (drawingData && !this.editModel.imageLink) {
|
||||
this.editModel.imageLink = drawingData;
|
||||
}
|
||||
}
|
||||
// Nicht automatisch speichern, nur wenn User explizit "Speichern" klickt
|
||||
},
|
||||
onUpdateFields(fields) {
|
||||
if (this.editModel) {
|
||||
this.editModel.code = fields.code;
|
||||
this.editModel.name = fields.name;
|
||||
this.editModel.description = fields.description;
|
||||
}
|
||||
},
|
||||
onUpdateDrawingData(data) {
|
||||
if (this.editModel) {
|
||||
this.editModel.drawingData = data;
|
||||
}
|
||||
},
|
||||
|
||||
async onDrawingImageUpload(file, drawingData) {
|
||||
console.log('onDrawingImageUpload called with file:', file);
|
||||
console.log('onDrawingImageUpload called with drawingData:', drawingData);
|
||||
console.log('File type:', file?.type);
|
||||
console.log('File size:', file?.size);
|
||||
console.log('File name:', file?.name);
|
||||
|
||||
// Setze das File und die Zeichnungsdaten für den Upload
|
||||
this.selectedFile = file;
|
||||
this.selectedDrawingData = drawingData;
|
||||
console.log('selectedFile set to:', this.selectedFile);
|
||||
console.log('selectedDrawingData set to:', this.selectedDrawingData);
|
||||
|
||||
// Upload wird erst beim Speichern durchgeführt
|
||||
console.log('File and drawing data ready for upload when saving');
|
||||
},
|
||||
|
||||
async onImageUploaded() {
|
||||
console.log('Image uploaded successfully, refreshing image list...');
|
||||
// Bildliste aktualisieren
|
||||
if (this.editModel && this.editModel.id) {
|
||||
await this.select(this.editModel);
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
@@ -354,5 +490,19 @@ input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.drawing-section {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.drawing-section h5 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user