1 Commits

14 changed files with 154 additions and 2468 deletions

View File

@@ -5,8 +5,8 @@ import fs from 'fs';
export const createPredefinedActivity = async (req, res) => {
try {
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData });
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);
@@ -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, drawingData } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData });
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);

View File

@@ -33,15 +33,10 @@ 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

View File

@@ -1,11 +0,0 @@
-- 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`;

View File

@@ -19,11 +19,6 @@ 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,

View File

@@ -19,11 +19,6 @@ 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,

View File

@@ -23,7 +23,6 @@ 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);

View File

@@ -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: '3h' });
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
await UserToken.create({
userId: user.id,
token,
expiresAt: new Date(Date.now() + 3 * 3600 * 1000),
expiresAt: new Date(Date.now() + 3600 * 1000),
});
return { token };
};

View File

@@ -2,7 +2,6 @@ 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';
@@ -13,47 +12,15 @@ class DiaryDateActivityService {
await checkAccess(userToken, clubId);
console.log('[DiaryDateActivityService::createActivity] - add: ', data);
const { activity, ...restData } = data;
// 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 }
]
}
});
}
}
let predefinedActivity = await PredefinedActivity.findOne({ where: { name: data.activity } });
if (!predefinedActivity) {
predefinedActivity = await PredefinedActivity.create({
name: data.name || data.activity || '',
code: data.code || (data.activity || ''),
description: data.description || '',
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : null
name: data.activity,
description: '',
duration: data.duration
});
}
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 }
});
@@ -87,8 +54,8 @@ class DiaryDateActivityService {
console.log('[DiaryDateActivityService::updateActivity] - creating new PredefinedActivity');
predefinedActivity = await PredefinedActivity.create({
name: data.customActivityName,
description: data.description || '',
duration: data.duration && data.duration !== '' ? parseInt(data.duration) : (activity.duration || null)
description: '',
duration: data.duration || activity.duration
});
}
@@ -164,7 +131,9 @@ 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']],
@@ -172,12 +141,6 @@ class DiaryDateActivityService {
{
model: PredefinedActivity,
as: 'predefinedActivity',
include: [
{
model: PredefinedActivityImage,
as: 'images'
}
]
},
{
model: GroupActivity,
@@ -195,64 +158,8 @@ class DiaryDateActivityService {
}
]
});
// 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;
console.log(`[DiaryDateActivityService::getActivities] - found ${activities.length} activities`);
return activities;
}
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity) {

View File

@@ -15,7 +15,6 @@ class PredefinedActivityService {
durationText: data.durationText,
duration: data.duration,
imageLink: data.imageLink,
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
});
}
@@ -33,7 +32,6 @@ class PredefinedActivityService {
durationText: data.durationText,
duration: data.duration,
imageLink: data.imageLink,
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
});
}

View File

@@ -1,442 +0,0 @@
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -126,11 +126,10 @@
</span>
<span v-else @click="startActivityEdit(item)" class="clickable activity-label"
:title="item.predefinedActivity && item.predefinedActivity.name ? item.predefinedActivity.name : ''">
<!-- 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>
<span v-if="item.predefinedActivity && item.predefinedActivity.imageLink"
@click.stop="showActivityImage(item.predefinedActivity.imageLink)"
class="image-icon"
title="Bild anzeigen">🖼</span>
{{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '')
? item.predefinedActivity.code
: (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
@@ -162,12 +161,10 @@
<td></td>
<td>
<span class="activity-label" :title="(groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.name) ? groupItem.groupPredefinedActivity.name : ''">
<!-- 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>
<span v-if="groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.imageLink"
@click.stop="showActivityImage(groupItem.groupPredefinedActivity.imageLink)"
class="image-icon"
title="Bild anzeigen">🖼</span>
{{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '')
? groupItem.groupPredefinedActivity.code
: groupItem.groupPredefinedActivity.name }}
@@ -322,13 +319,8 @@
</div>
</div>
</div>
<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 v-if="showImage" class="memberImage">
<img :src="imageUrl" @click="closeImage" />
</div>
<div v-if="showAccidentForm" class="accidentForm">
<form @submit.prevent="submitAccident">
@@ -353,7 +345,7 @@
+ accident.accident}}</li>
</ul>
</form>
</div>
</div>
<!-- Schnell hinzufügen Dialog -->
<div v-if="showQuickAddDialog" class="modal-overlay" @click.self="closeQuickAddDialog">
@@ -405,11 +397,10 @@ 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, CourtDrawingRender },
components: { Multiselect },
data() {
return {
date: null,
@@ -445,8 +436,6 @@ export default {
showDropdown: false,
showImage: false,
imageUrl: '',
showRenderModal: false,
renderModalData: null,
groups: [],
currentTimeBlockId: null,
newGroupName: '',
@@ -537,36 +526,6 @@ 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}`);
@@ -1160,43 +1119,12 @@ export default {
},
closeImage() {
this.showImage = false;
this.showRenderModal = false;
this.renderModalData = null;
this.imageUrl = '';
},
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);
}
showActivityImage(imageLink) {
this.imageUrl = imageLink;
this.showImage = true;
},
async loadMemberImage(member) {
try {
@@ -1982,12 +1910,6 @@ 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;

View File

@@ -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, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben oder ein Bild hochladen:</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,18 +74,6 @@
</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">
@@ -109,13 +97,9 @@
<script>
import apiClient from '../apiClient.js';
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
export default {
name: 'PredefinedActivities',
components: {
CourtDrawingTool
},
data() {
return {
activities: [],
@@ -123,7 +107,6 @@ export default {
editModel: null,
images: [],
selectedFile: null,
selectedDrawingData: null,
mergeSourceId: '',
mergeTargetId: '',
};
@@ -148,15 +131,6 @@ 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 || [];
@@ -166,24 +140,7 @@ export default {
const r = await apiClient.get(`/predefined-activities/${a.id}`);
const { images, ...activity } = r.data;
this.images = images || [];
// 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);
}
}
this.editModel = { ...activity };
},
formatItem(a) {
return `${a.code ? '[' + a.code + '] ' : ''}${a.name}`;
@@ -207,7 +164,6 @@ export default {
duration: null,
durationText: '',
imageLink: '',
drawingData: '',
};
},
cancel() {
@@ -217,81 +173,36 @@ 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 = this.normalizeActivity(r.data);
this.editModel = r.data;
} else {
const r = await apiClient.post('/predefined-activities', this.editModel);
this.editModel = this.normalizeActivity(r.data);
this.editModel = r.data;
// Nach dem Erstellen einer neuen Aktivität, falls ein Bild ausgewählt wurde, hochladen
if (this.selectedFile) {
await this.uploadImage();
}
}
// 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 `http://localhost:3000/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
},
async uploadImage() {
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);
if (!this.editModel || !this.editModel.id || !this.selectedFile) return;
const fd = new FormData();
fd.append('image', this.selectedFile);
// 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;
}
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 deleteImage(imageId) {
if (!this.editModel || !this.editModel.id) return;
@@ -309,53 +220,6 @@ 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() {
@@ -490,19 +354,5 @@ 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>

View File

@@ -2,13 +2,16 @@
<div>
<h2>Spielpläne</h2>
<button @click="openImportModal">Spielplanimport</button>
<div v-if="hoveredMatch" class="hover-info">
<p><strong>{{ hoveredMatch.location.name }}</strong></p>
<p>{{ hoveredMatch.location.address }}</p>
<p>{{ hoveredMatch.location.zip }} {{ hoveredMatch.location.city }}</p>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
<p>{{ hoveredMatch.location.zip || '' }} {{ hoveredMatch.location.city || 'N/A' }}</p>
</div>
<div class="output">
<ul>
<li class="special-link" @click="loadAllMatches">Gesamtspielplan</li>
<li class="special-link" @click="loadAdultMatches">Spielplan Erwachsene</li>
<li class="divider"></li>
<li v-for="league in leagues" :key="league" @click="loadMatchesForLeague(league.id, league.name)">{{
league.name }}</li>
</ul>
@@ -23,15 +26,17 @@
<th>Uhrzeit</th>
<th>Heimmannschaft</th>
<th>Gastmannschaft</th>
<th v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">Altersklasse</th>
</tr>
</thead>
<tbody>
<tr v-for="match in matches" :key="match.id" @mouseover="hoveredMatch = match"
@mouseleave="hoveredMatch = null">
@mouseleave="hoveredMatch = null" :class="getRowClass(match.date)">
<td>{{ formatDate(match.date) }}</td>
<td>{{ match.time.toString().slice(0, 5) }} Uhr</td>
<td v-html="highlightClubName(match.homeTeam.name)"></td>
<td v-html="highlightClubName(match.guestTeam.name)"></td>
<td>{{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }}</td>
<td v-html="highlightClubName(match.homeTeam?.name || 'N/A')"></td>
<td v-html="highlightClubName(match.guestTeam?.name || 'N/A')"></td>
<td v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">{{ match.leagueDetails?.name || 'N/A' }}</td>
</tr>
</tbody>
</table>
@@ -185,8 +190,37 @@ export default {
this.matches = [];
}
},
async loadAllMatches() {
this.selectedLeague = 'Gesamtspielplan';
try {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
this.matches = response.data;
} catch (error) {
alert('Fehler beim Laden des Gesamtspielplans');
this.matches = [];
}
},
async loadAdultMatches() {
this.selectedLeague = 'Spielplan Erwachsene';
try {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
// Filtere nur Erwachsenenligen (keine Jugendligen)
const allMatches = response.data;
this.matches = allMatches.filter(match => {
const leagueName = match.leagueDetails?.name || '';
// Prüfe, ob es eine Jugendliga ist (J, M, Jugend im Namen)
const isYouth = /[JM]\d|jugend/i.test(leagueName);
return !isYouth;
});
} catch (error) {
alert('Fehler beim Laden des Erwachsenenspielplans');
this.matches = [];
}
},
formatDate(date) {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const wd = weekdays[d.getDay()];
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
@@ -194,6 +228,7 @@ export default {
return `${wd} ${day}`;
},
highlightClubName(teamName) {
if (!teamName) return 'N/A';
const clubName = this.currentClubName;
if (clubName && teamName.includes(clubName)) {
return `<strong>${teamName}</strong>`;
@@ -228,12 +263,13 @@ export default {
getUniqueLocations() {
const uniqueLocations = new Map();
this.matches.forEach(match => {
if (!match.location || !match.homeTeam) return;
const location = match.location;
const clubName = match.homeTeam.name;
const addressLines = [
location.name,
location.address,
`${location.zip} ${location.city}`
location.name || 'N/A',
location.address || 'N/A',
`${location.zip || ''} ${location.city || ''}`.trim()
];
const addressKey = addressLines.join('; ');
if (!uniqueLocations.has(addressKey)) {
@@ -243,6 +279,28 @@ export default {
return uniqueLocations;
},
getRowClass(matchDate) {
if (!matchDate) return '';
const today = new Date();
const match = new Date(matchDate);
// Setze die Zeit auf Mitternacht für genaue Datumsvergleiche
today.setHours(0, 0, 0, 0);
match.setHours(0, 0, 0, 0);
// Berechne die Differenz in Tagen
const diffTime = match.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'match-today'; // Heute - gelb
} else if (diffDays > 0 && diffDays <= 7) {
return 'match-next-week'; // Nächste Woche - hellblau
}
return ''; // Keine besondere Farbe
},
},
async created() {
await this.loadLeagues();
@@ -355,4 +413,42 @@ li {
color: #45a049;
cursor: pointer;
}
.special-link {
font-weight: bold;
color: #2c5aa0 !important;
padding: 5px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 5px;
}
.special-link:hover {
background-color: #f0f8ff;
padding-left: 5px;
transition: all 0.3s ease;
}
.divider {
height: 1px;
background-color: #ddd;
margin: 10px 0;
cursor: default !important;
color: transparent !important;
}
.match-today {
background-color: #fff3cd !important; /* Gelb für heute */
}
.match-next-week {
background-color: #d1ecf1 !important; /* Hellblau für nächste Woche */
}
.match-today:hover {
background-color: #ffeaa7 !important; /* Dunkleres Gelb beim Hover */
}
.match-next-week:hover {
background-color: #b8daff !important; /* Dunkleres Blau beim Hover */
}
</style>