Compare commits
7 Commits
4d8cc643a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 425660f139 | |||
| d5a744652d | |||
| e9d8a1a818 | |||
| da90cfa891 | |||
| 05ad4128e4 | |||
| 133795f744 | |||
| 8c1508f3ae |
@ -7,6 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node server.js",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@ -38,6 +39,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/adapter-static": "^3.0.8"
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"express": "^5.1.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
792
pnpm-lock.yaml
generated
119
server.js
Normal file
@ -0,0 +1,119 @@
|
||||
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server);
|
||||
|
||||
const eventTimestamps = {};
|
||||
const EVENT_LIMIT = 100; // Max events per second per user
|
||||
const TIME_FRAME = 1000; // 1 second in milliseconds
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('a user connected');
|
||||
|
||||
// Middleware to check for event spamming
|
||||
socket.use((packet, next) => {
|
||||
const userId = socket.id;
|
||||
const eventName = packet[0];
|
||||
const now = Date.now();
|
||||
|
||||
if (!eventTimestamps[userId]) {
|
||||
eventTimestamps[userId] = [];
|
||||
}
|
||||
|
||||
// Remove timestamps older than the time frame
|
||||
eventTimestamps[userId] = eventTimestamps[userId].filter(ts => now - ts < TIME_FRAME);
|
||||
|
||||
// Check if the user has exceeded the event limit
|
||||
if (eventTimestamps[userId].length >= EVENT_LIMIT) {
|
||||
console.warn(`User ${userId} is sending too many events (${eventName}). Throttling.`);
|
||||
return next(new Error('Too many events'));
|
||||
}
|
||||
|
||||
eventTimestamps[userId].push(now);
|
||||
next();
|
||||
});
|
||||
|
||||
socket.on('join', (room) => {
|
||||
socket.join(room);
|
||||
console.log(`User joined room: ${room}`);
|
||||
});
|
||||
|
||||
socket.on('request-state', (data) => {
|
||||
socket.to(data.room).emit('request-state', { from: socket.id });
|
||||
});
|
||||
|
||||
socket.on('sync-state', (data) => {
|
||||
socket.to(data.to).emit('sync-state', data);
|
||||
});
|
||||
|
||||
socket.on('card-move', (data) => {
|
||||
socket.to(data.room).emit('card-move', data);
|
||||
});
|
||||
|
||||
socket.on('card-click', (data) => {
|
||||
socket.to(data.room).emit('card-click', data);
|
||||
});
|
||||
|
||||
socket.on('card-drop', (data) => {
|
||||
socket.to(data.room).emit('card-drop', data);
|
||||
});
|
||||
|
||||
socket.on('sticky-note-add', (data) => {
|
||||
socket.to(data.room).emit('sticky-note-add', data);
|
||||
});
|
||||
|
||||
socket.on('sticky-note-move', (data) => {
|
||||
socket.to(data.room).emit('sticky-note-move', data);
|
||||
});
|
||||
|
||||
socket.on('sticky-note-update', (data) => {
|
||||
socket.to(data.room).emit('sticky-note-update', data);
|
||||
});
|
||||
|
||||
socket.on('sticky-note-delete', (data) => {
|
||||
socket.to(data.room).emit('sticky-note-delete', data);
|
||||
});
|
||||
|
||||
socket.on('deck-change', (data) => {
|
||||
socket.to(data.room).emit('deck-change', data);
|
||||
});
|
||||
|
||||
socket.on('shuffle-deck', (data) => {
|
||||
socket.to(data.room).emit('shuffle-deck', data);
|
||||
});
|
||||
|
||||
socket.on('collect-to-deck', (data) => {
|
||||
socket.to(data.room).emit('collect-to-deck', data);
|
||||
});
|
||||
|
||||
socket.on('deal-cards', (data) => {
|
||||
socket.to(data.room).emit('deal-cards', data);
|
||||
});
|
||||
|
||||
socket.on('flip-all-cards', (data) => {
|
||||
socket.to(data.room).emit('flip-all-cards', data);
|
||||
});
|
||||
|
||||
socket.on('shuffle-mode-change', (data) => {
|
||||
socket.to(data.room).emit('shuffle-mode-change', data);
|
||||
});
|
||||
|
||||
socket.on('import-canvas', (data) => {
|
||||
socket.to(data.room).emit('import-canvas', data);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('user disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
app.use(handler);
|
||||
|
||||
server.listen(3000, () => {
|
||||
console.log('listening on *:3000');
|
||||
});
|
||||
@ -7,6 +7,8 @@
|
||||
x?: number;
|
||||
y?: number;
|
||||
zIndex?: number;
|
||||
canvasOffset?: { x: number; y: number };
|
||||
canvasZoom?: number;
|
||||
onMove?: (id: number, x: number, y: number) => void;
|
||||
onClick?: (id: number) => void;
|
||||
onDrop?: (id: number, x: number, y: number) => void;
|
||||
@ -20,6 +22,8 @@
|
||||
x = $bindable(0),
|
||||
y = $bindable(0),
|
||||
zIndex = $bindable(0),
|
||||
canvasOffset = { x: 0, y: 0 },
|
||||
canvasZoom = 1,
|
||||
onMove,
|
||||
onClick,
|
||||
onDrop
|
||||
@ -36,10 +40,11 @@
|
||||
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
const rect = cardElement.getBoundingClientRect();
|
||||
|
||||
// Calculate offset relative to card position in canvas coordinates
|
||||
dragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
x: (event.clientX - canvasOffset.x) / canvasZoom - x,
|
||||
y: (event.clientY - canvasOffset.y) / canvasZoom - y
|
||||
};
|
||||
dragStartPos = {
|
||||
x: event.clientX,
|
||||
@ -59,8 +64,8 @@
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
const newX = event.clientX - dragOffset.x;
|
||||
const newY = event.clientY - dragOffset.y;
|
||||
const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
|
||||
const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
|
||||
|
||||
x = newX;
|
||||
y = newY;
|
||||
@ -82,6 +87,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) return;
|
||||
event.stopPropagation(); // Prevent canvas panning
|
||||
|
||||
const touch = event.touches[0];
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate offset relative to card position in canvas coordinates
|
||||
dragOffset = {
|
||||
x: (touch.clientX - canvasOffset.x) / canvasZoom - x,
|
||||
y: (touch.clientY - canvasOffset.y) / canvasZoom - y
|
||||
};
|
||||
dragStartPos = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
if (!isDragging || event.touches.length !== 1) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - dragStartPos.x);
|
||||
const deltaY = Math.abs(touch.clientY - dragStartPos.y);
|
||||
|
||||
if (deltaX > 10 || deltaY > 10) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
const newX = (touch.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
|
||||
const newY = (touch.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
|
||||
|
||||
x = newX;
|
||||
y = newY;
|
||||
|
||||
onMove?.(id, newX, newY);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
if (isDragging) {
|
||||
onDrop?.(id, x, y);
|
||||
if (!hasMoved) {
|
||||
flipped = !flipped;
|
||||
onClick?.(id);
|
||||
}
|
||||
}
|
||||
isDragging = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@ -102,6 +161,9 @@
|
||||
style="left: {x}px; top: {y}px; z-index: {zIndex};"
|
||||
onmousedown={handleMouseDown}
|
||||
onclick={handleClick}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
282
src/lib/StickyNote.svelte
Normal file
@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: number;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zIndex: number;
|
||||
color: string;
|
||||
canvasOffset?: { x: number; y: number };
|
||||
canvasZoom?: number;
|
||||
onMove?: (id: number, x: number, y: number) => void;
|
||||
onUpdate?: (id: number, text: string) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
text = $bindable(''),
|
||||
x = $bindable(0),
|
||||
y = $bindable(0),
|
||||
zIndex = $bindable(0),
|
||||
color,
|
||||
canvasOffset = { x: 0, y: 0 },
|
||||
canvasZoom = 1,
|
||||
onMove,
|
||||
onUpdate,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragOffset = $state({ x: 0, y: 0 });
|
||||
let dragStartPos = $state({ x: 0, y: 0 });
|
||||
let hasMoved = $state(false);
|
||||
let noteElement: HTMLDivElement;
|
||||
let textareaElement: HTMLTextAreaElement;
|
||||
let isEditing = $state(false);
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate offset relative to note position in canvas coordinates
|
||||
dragOffset = {
|
||||
x: (event.clientX - canvasOffset.x) / canvasZoom - x,
|
||||
y: (event.clientY - canvasOffset.y) / canvasZoom - y
|
||||
};
|
||||
dragStartPos = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaX = Math.abs(event.clientX - dragStartPos.x);
|
||||
const deltaY = Math.abs(event.clientY - dragStartPos.y);
|
||||
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
|
||||
const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
|
||||
|
||||
x = newX;
|
||||
y = newY;
|
||||
|
||||
onMove?.(id, newX, newY);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!hasMoved) {
|
||||
isEditing = true;
|
||||
setTimeout(() => textareaElement?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextBlur() {
|
||||
isEditing = false;
|
||||
onUpdate?.(id, text);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
isEditing = false;
|
||||
onUpdate?.(id, text);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
isEditing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
onDelete?.(id);
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) return;
|
||||
event.stopPropagation(); // Prevent canvas panning
|
||||
|
||||
const touch = event.touches[0];
|
||||
isDragging = true;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate offset relative to note position in canvas coordinates
|
||||
dragOffset = {
|
||||
x: (touch.clientX - canvasOffset.x) / canvasZoom - x,
|
||||
y: (touch.clientY - canvasOffset.y) / canvasZoom - y
|
||||
};
|
||||
dragStartPos = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
if (!isDragging || event.touches.length !== 1) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - dragStartPos.x);
|
||||
const deltaY = Math.abs(touch.clientY - dragStartPos.y);
|
||||
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
const newX = (touch.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
|
||||
const newY = (touch.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
|
||||
|
||||
x = newX;
|
||||
y = newY;
|
||||
|
||||
onMove?.(id, newX, newY);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
if (isDragging) {
|
||||
if (!hasMoved) {
|
||||
isEditing = true;
|
||||
setTimeout(() => textareaElement?.focus(), 0);
|
||||
}
|
||||
}
|
||||
isDragging = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={noteElement}
|
||||
class="sticky-note"
|
||||
class:dragging={isDragging}
|
||||
class:editing={isEditing}
|
||||
style="left: {x}px; top: {y}px; z-index: {zIndex}; background-color: {color};"
|
||||
onmousedown={handleMouseDown}
|
||||
onclick={handleClick}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<button class="delete-btn" onclick={handleDelete}>×</button>
|
||||
|
||||
{#if isEditing}
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={text}
|
||||
onblur={handleTextBlur}
|
||||
onkeydown={handleKeyDown}
|
||||
class="note-textarea"
|
||||
placeholder="Enter your note..."
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="note-text">{text || 'Click to edit'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sticky-note {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
min-height: 120px;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.sticky-note:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.sticky-note.dragging {
|
||||
cursor: grabbing;
|
||||
z-index: 1000 !important;
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.sticky-note.dragging:hover {
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.note-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
min-height: 80px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.note-text:empty::before {
|
||||
content: 'Click to edit';
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/Card.svelte';
|
||||
import StickyNote from '$lib/StickyNote.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
interface CardData {
|
||||
id: number;
|
||||
@ -9,25 +12,282 @@
|
||||
y: number;
|
||||
zIndex: number;
|
||||
inDeck: boolean;
|
||||
isRiskCategory?: boolean;
|
||||
}
|
||||
|
||||
const cardImages = Array.from({ length: 14 }, (_, i) => `front${i + 1}.png`);
|
||||
const backImage = 'back.png';
|
||||
interface DeckConfig {
|
||||
name: string;
|
||||
backImage: string;
|
||||
cards: string[];
|
||||
riskCategories?: string[];
|
||||
}
|
||||
|
||||
interface StickyNoteData {
|
||||
id: number;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zIndex: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const deckConfigs: DeckConfig[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
backImage: 'back.png',
|
||||
cards: Array.from({ length: 14 }, (_, i) => `front${i + 1}.png`)
|
||||
},
|
||||
{
|
||||
name: 'Risk Cards',
|
||||
backImage: 'risks/risiko_back.png',
|
||||
cards: [
|
||||
'risks/altsysteme_und_altlasten1.png',
|
||||
'risks/altsysteme_und_altlasten2.png',
|
||||
'risks/altsysteme_und_altlasten3.png',
|
||||
'risks/altsysteme_und_altlasten4.png',
|
||||
'risks/altsysteme_und_altlasten5.png',
|
||||
'risks/betrieb_und_deployment1.png',
|
||||
'risks/betrieb_und_deployment2.png',
|
||||
'risks/betrieb_und_deployment3.png',
|
||||
'risks/betrieb_und_deployment4.png',
|
||||
'risks/betrieb_und_deployment5.png',
|
||||
'risks/fremdsysteme_und_plattformen1.png',
|
||||
'risks/fremdsysteme_und_plattformen2.png',
|
||||
'risks/fremdsysteme_und_plattformen3.png',
|
||||
'risks/fremdsysteme_und_plattformen4.png',
|
||||
'risks/fremdsysteme_und_plattformen5.png',
|
||||
'risks/kompetenz_und_erfahrung1.png',
|
||||
'risks/kompetenz_und_erfahrung2.png',
|
||||
'risks/kompetenz_und_erfahrung3.png',
|
||||
'risks/kompetenz_und_erfahrung4.png',
|
||||
'risks/kompetenz_und_erfahrung5.png',
|
||||
'risks/orga_und_prozesse1.png',
|
||||
'risks/orga_und_prozesse2.png',
|
||||
'risks/orga_und_prozesse3.png',
|
||||
'risks/orga_und_prozesse4.png',
|
||||
'risks/orga_und_prozesse5.png',
|
||||
'risks/softwareloesung1.png',
|
||||
'risks/softwareloesung2.png',
|
||||
'risks/softwareloesung3.png',
|
||||
'risks/softwareloesung4.png',
|
||||
'risks/softwareloesung5.png',
|
||||
'risks/weiche_faktoren1.png',
|
||||
'risks/weiche_faktoren2.png',
|
||||
'risks/weiche_faktoren3.png',
|
||||
'risks/weiche_faktoren4.png',
|
||||
'risks/weiche_faktoren5.png',
|
||||
'risks/zielsetzung1.png',
|
||||
'risks/zielsetzung2.png',
|
||||
'risks/zielsetzung3.png',
|
||||
'risks/zielsetzung4.png',
|
||||
'risks/zielsetzung5.png'
|
||||
],
|
||||
riskCategories: [
|
||||
'altsysteme_und_altlasten',
|
||||
'betrieb_und_deployment',
|
||||
'fremdsysteme_und_plattformen',
|
||||
'kompetenz_und_erfahrung',
|
||||
'orga_und_prozesse',
|
||||
'softwareloesung',
|
||||
'weiche_faktoren',
|
||||
'zielsetzung'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
let selectedDeck = $state(0);
|
||||
let shuffleMode = $state('normal');
|
||||
let currentDeckConfig = $derived(deckConfigs[selectedDeck]);
|
||||
let backImage = $derived(currentDeckConfig.backImage);
|
||||
|
||||
let deckPosition = $state({ x: 50, y: 300 });
|
||||
let canvasOffset = $state({ x: 0, y: 0 });
|
||||
let canvasZoom = $state(1);
|
||||
let isDragging = $state(false);
|
||||
let dragStart = $state({ x: 0, y: 0 });
|
||||
let dragStartOffset = $state({ x: 0, y: 0 });
|
||||
let isPinching = $state(false);
|
||||
let lastPinchDistance = $state(0);
|
||||
let pinchCenter = $state({ x: 0, y: 0 });
|
||||
|
||||
let cards = $state<CardData[]>(
|
||||
cardImages.map((image, index) => ({
|
||||
let cards = $state<CardData[]>([]);
|
||||
let maxZIndex = $state(0);
|
||||
let stickyNotes = $state<StickyNoteData[]>([]);
|
||||
let nextNoteId = $state(1);
|
||||
let noteColors = ['#ffeb3b', '#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0'];
|
||||
|
||||
let socket: Socket;
|
||||
let room: string;
|
||||
let isStateSynced = false;
|
||||
let lastEmitTime = 0;
|
||||
|
||||
onMount(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
room = urlParams.get('sessid') || `room-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (!urlParams.has('sessid')) {
|
||||
window.history.replaceState({}, '', `?sessid=${room}`);
|
||||
}
|
||||
|
||||
socket = io();
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('join', room);
|
||||
|
||||
// Request state from other clients, but only if we haven't synced yet
|
||||
if (!isStateSynced) {
|
||||
socket.emit('request-state', { room });
|
||||
}
|
||||
});
|
||||
|
||||
// If no one sends state within 2 seconds, initialize the deck
|
||||
setTimeout(() => {
|
||||
if (!isStateSynced) {
|
||||
initializeDeck();
|
||||
isStateSynced = true;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
socket.on('request-state', (data) => {
|
||||
const fullState = {
|
||||
cards,
|
||||
stickyNotes,
|
||||
selectedDeck,
|
||||
shuffleMode,
|
||||
deckPosition,
|
||||
maxZIndex,
|
||||
nextNoteId
|
||||
};
|
||||
socket.emit('sync-state', { to: data.from, room, state: fullState });
|
||||
});
|
||||
|
||||
socket.on('sync-state', (data) => {
|
||||
if (!isStateSynced) {
|
||||
restoreCanvasState(data.state);
|
||||
isStateSynced = true;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('card-move', (data) => {
|
||||
const card = cards.find(c => c.id === data.id);
|
||||
if (card) {
|
||||
card.x = data.x;
|
||||
card.y = data.y;
|
||||
card.zIndex = data.zIndex;
|
||||
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('card-click', (data) => {
|
||||
const card = cards.find(c => c.id === data.id);
|
||||
if (card) {
|
||||
card.flipped = !card.flipped;
|
||||
card.zIndex = data.zIndex;
|
||||
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('card-drop', (data) => {
|
||||
const card = cards.find(c => c.id === data.id);
|
||||
if (card) {
|
||||
Object.assign(card, data.cardState);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('sticky-note-add', (data) => {
|
||||
stickyNotes.push(data.note);
|
||||
if (data.note.id >= nextNoteId) nextNoteId = data.note.id + 1;
|
||||
if (data.note.zIndex > maxZIndex) maxZIndex = data.note.zIndex;
|
||||
});
|
||||
|
||||
socket.on('sticky-note-move', (data) => {
|
||||
const note = stickyNotes.find(n => n.id === data.id);
|
||||
if (note) {
|
||||
note.x = data.x;
|
||||
note.y = data.y;
|
||||
note.zIndex = data.zIndex;
|
||||
if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('sticky-note-update', (data) => {
|
||||
const note = stickyNotes.find(n => n.id === data.id);
|
||||
if (note) {
|
||||
note.text = data.text;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('sticky-note-delete', (data) => {
|
||||
const index = stickyNotes.findIndex(n => n.id === data.id);
|
||||
if (index >= 0) {
|
||||
stickyNotes.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('deck-change', (data) => {
|
||||
selectedDeck = data.selectedDeck;
|
||||
initializeDeck(false); // Don't broadcast, just init
|
||||
});
|
||||
|
||||
socket.on('shuffle-deck', (data) => {
|
||||
cards = data.cards;
|
||||
});
|
||||
|
||||
socket.on('collect-to-deck', (data) => {
|
||||
cards = data.cards;
|
||||
});
|
||||
|
||||
socket.on('deal-cards', (data) => {
|
||||
cards = data.cards;
|
||||
});
|
||||
|
||||
socket.on('flip-all-cards', () => {
|
||||
cards.forEach(card => card.flipped = !card.flipped);
|
||||
});
|
||||
|
||||
socket.on('shuffle-mode-change', (data) => {
|
||||
shuffleMode = data.shuffleMode;
|
||||
});
|
||||
|
||||
socket.on('import-canvas', (data) => {
|
||||
restoreCanvasState(data.state);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function initializeDeck(broadcast = true) {
|
||||
const config = deckConfigs[selectedDeck];
|
||||
cards = config.cards.map((image, index) => ({
|
||||
id: index + 1,
|
||||
frontImage: image,
|
||||
flipped: true,
|
||||
x: deckPosition.x + index * 2,
|
||||
y: deckPosition.y + index * 2,
|
||||
zIndex: index,
|
||||
inDeck: true
|
||||
}))
|
||||
);
|
||||
let maxZIndex = $state(cards.length);
|
||||
inDeck: true,
|
||||
isRiskCategory: selectedDeck === 1 && image.includes('1.png')
|
||||
}));
|
||||
maxZIndex = cards.length;
|
||||
stickyNotes = [];
|
||||
nextNoteId = 1;
|
||||
|
||||
if (broadcast && socket) {
|
||||
socket.emit('deck-change', { room, selectedDeck });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeckChange() {
|
||||
if (!confirm('Are you sure you want to change the deck? This will reset the current session.')) {
|
||||
// If the user cancels, revert the selection
|
||||
const previousDeck = cards.length > 0 ? deckConfigs.findIndex(d => d.cards[0] === cards[0].frontImage) : 0;
|
||||
selectedDeck = previousDeck;
|
||||
return;
|
||||
}
|
||||
initializeDeck();
|
||||
}
|
||||
|
||||
function handleCardMove(id: number, x: number, y: number) {
|
||||
const card = cards.find(c => c.id === id);
|
||||
@ -35,21 +295,28 @@
|
||||
card.x = x;
|
||||
card.y = y;
|
||||
card.zIndex = ++maxZIndex;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastEmitTime > 16) { // Throttle to ~60fps
|
||||
socket.emit('card-move', { room, id, x, y, zIndex: card.zIndex });
|
||||
lastEmitTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardDrop(id: number, x: number, y: number) {
|
||||
const card = cards.find(c => c.id === id);
|
||||
if (card) {
|
||||
// Check if card is dropped on deck area
|
||||
const cardCenterX = x + 110;
|
||||
const cardCenterY = y + 150;
|
||||
|
||||
if (
|
||||
x >= deckPosition.x &&
|
||||
x <= deckPosition.x + 220 &&
|
||||
y >= deckPosition.y &&
|
||||
y <= deckPosition.y + 300 &&
|
||||
cardCenterX >= deckPosition.x &&
|
||||
cardCenterX <= deckPosition.x + 220 &&
|
||||
cardCenterY >= deckPosition.y &&
|
||||
cardCenterY <= deckPosition.y + 300 &&
|
||||
!card.inDeck
|
||||
) {
|
||||
// Move card to top of deck
|
||||
const deckCards = cards.filter(c => c.inDeck);
|
||||
const topZ = deckCards.length > 0 ? Math.max(...deckCards.map(c => c.zIndex)) + 1 : 0;
|
||||
|
||||
@ -59,9 +326,9 @@
|
||||
card.flipped = true;
|
||||
card.inDeck = true;
|
||||
} else if (card.inDeck) {
|
||||
// Card was moved out of deck
|
||||
card.inDeck = false;
|
||||
}
|
||||
socket.emit('card-drop', { room, id, cardState: { ...card } });
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,6 +336,7 @@
|
||||
const card = cards.find(c => c.id === id);
|
||||
if (card) {
|
||||
card.zIndex = ++maxZIndex;
|
||||
socket.emit('card-click', { room, id, zIndex: card.zIndex });
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +344,21 @@
|
||||
const deckCards = cards.filter(card => card.inDeck);
|
||||
if (deckCards.length === 0) return;
|
||||
|
||||
if (selectedDeck === 1) {
|
||||
if (shuffleMode === 'shuffle-only-risks') {
|
||||
shuffleOnlyRisks();
|
||||
} else if (shuffleMode === 'shuffle-per-category') {
|
||||
shufflePerCategory();
|
||||
} else {
|
||||
normalShuffle(deckCards);
|
||||
}
|
||||
} else {
|
||||
normalShuffle(deckCards);
|
||||
}
|
||||
socket.emit('shuffle-deck', { room, cards });
|
||||
}
|
||||
|
||||
function normalShuffle(deckCards: CardData[]) {
|
||||
for (let i = deckCards.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[deckCards[i], deckCards[j]] = [deckCards[j], deckCards[i]];
|
||||
@ -89,7 +372,72 @@
|
||||
});
|
||||
}
|
||||
|
||||
function shuffleOnlyRisks() {
|
||||
const deckCards = cards.filter(card => card.inDeck);
|
||||
const riskCategories = deckCards.filter(card => card.isRiskCategory);
|
||||
const riskCards = deckCards.filter(card => !card.isRiskCategory);
|
||||
|
||||
for (let i = riskCards.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[riskCards[i], riskCards[j]] = [riskCards[j], riskCards[i]];
|
||||
}
|
||||
|
||||
const shuffled = [...riskCategories, ...riskCards];
|
||||
shuffled.reverse();
|
||||
|
||||
const numCategories = riskCategories.length;
|
||||
const totalCards = shuffled.length;
|
||||
|
||||
shuffled.forEach((card, index) => {
|
||||
if (index >= totalCards - numCategories) {
|
||||
const categoryIndex = index - (totalCards - numCategories);
|
||||
card.x = 50 + 330 + (categoryIndex % 6) * 240;
|
||||
card.y = 50 + Math.floor(categoryIndex / 6) * 320;
|
||||
card.zIndex = index;
|
||||
card.flipped = false;
|
||||
card.inDeck = false;
|
||||
} else {
|
||||
card.x = deckPosition.x + index * 2;
|
||||
card.y = deckPosition.y + index * 2;
|
||||
card.zIndex = index;
|
||||
card.flipped = true;
|
||||
card.inDeck = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shufflePerCategory() {
|
||||
const deckCards = cards.filter(card => card.inDeck);
|
||||
const categories = currentDeckConfig.riskCategories || [];
|
||||
const shuffledDeck: CardData[] = [];
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryCard = deckCards.find(card => card.frontImage.includes(`${category}1.png`));
|
||||
const categoryRisks = deckCards.filter(card =>
|
||||
card.frontImage.includes(category) && !card.frontImage.includes('1.png')
|
||||
);
|
||||
|
||||
for (let i = categoryRisks.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[categoryRisks[i], categoryRisks[j]] = [categoryRisks[j], categoryRisks[i]];
|
||||
}
|
||||
|
||||
if (categoryCard) shuffledDeck.push(categoryCard);
|
||||
shuffledDeck.push(...categoryRisks);
|
||||
}
|
||||
|
||||
shuffledDeck.reverse();
|
||||
|
||||
shuffledDeck.forEach((card, index) => {
|
||||
card.x = deckPosition.x + index * 2;
|
||||
card.y = deckPosition.y + index * 2;
|
||||
card.zIndex = index;
|
||||
card.flipped = true;
|
||||
});
|
||||
}
|
||||
|
||||
function collectToDeck() {
|
||||
if (!confirm('Are you sure you want to collect all cards to the deck?')) return;
|
||||
cards.forEach((card, index) => {
|
||||
card.x = deckPosition.x + index * 2;
|
||||
card.y = deckPosition.y + index * 2;
|
||||
@ -97,38 +445,320 @@
|
||||
card.flipped = true;
|
||||
card.inDeck = true;
|
||||
});
|
||||
socket.emit('collect-to-deck', { room, cards });
|
||||
}
|
||||
|
||||
function dealCards() {
|
||||
if (!confirm('Are you sure you want to deal all cards?')) return;
|
||||
cards.forEach((card, index) => {
|
||||
card.x = 50 + (index % 6) * 240;
|
||||
card.x = 50 + 330 + (index % 6) * 240;
|
||||
card.y = 50 + Math.floor(index / 6) * 320;
|
||||
card.zIndex = index;
|
||||
card.flipped = false;
|
||||
card.inDeck = false;
|
||||
});
|
||||
socket.emit('deal-cards', { room, cards });
|
||||
}
|
||||
|
||||
function flipAllCards() {
|
||||
cards.forEach(card => {
|
||||
card.flipped = !card.flipped;
|
||||
});
|
||||
socket.emit('flip-all-cards', { room });
|
||||
}
|
||||
|
||||
function handleCanvasWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
const canvasCenterX = (centerX - canvasOffset.x) / canvasZoom;
|
||||
const canvasCenterY = (centerY - canvasOffset.y) / canvasZoom;
|
||||
canvasZoom = newZoom;
|
||||
canvasOffset.x = centerX - canvasCenterX * canvasZoom;
|
||||
canvasOffset.y = centerY - canvasCenterY * canvasZoom;
|
||||
} else {
|
||||
const scrollSpeed = 1;
|
||||
canvasOffset.x -= event.deltaX * scrollSpeed;
|
||||
canvasOffset.y -= event.deltaY * scrollSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasMouseDown(event: MouseEvent) {
|
||||
if (event.button === 1 || event.button === 2) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
dragStart.x = event.clientX;
|
||||
dragStart.y = event.clientY;
|
||||
dragStartOffset.x = canvasOffset.x;
|
||||
dragStartOffset.y = canvasOffset.y;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasMouseMove(event: MouseEvent) {
|
||||
if (isDragging) {
|
||||
event.preventDefault();
|
||||
const deltaX = event.clientX - dragStart.x;
|
||||
const deltaY = event.clientY - dragStart.y;
|
||||
canvasOffset.x = dragStartOffset.x + deltaX;
|
||||
canvasOffset.y = dragStartOffset.y + deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasMouseUp(event: MouseEvent) {
|
||||
if (event.button === 1 || event.button === 2) {
|
||||
isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCanvasContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function getTouchDistance(touches: TouchList) {
|
||||
const touch1 = touches[0];
|
||||
const touch2 = touches[1];
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
);
|
||||
}
|
||||
|
||||
function getTouchCenter(touches: TouchList) {
|
||||
const touch1 = touches[0];
|
||||
const touch2 = touches[1];
|
||||
return {
|
||||
x: (touch1.clientX + touch2.clientX) / 2,
|
||||
y: (touch1.clientY + touch2.clientY) / 2
|
||||
};
|
||||
}
|
||||
|
||||
function handleCanvasTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length >= 2) {
|
||||
isPinching = true;
|
||||
lastPinchDistance = getTouchDistance(event.touches);
|
||||
pinchCenter = getTouchCenter(event.touches);
|
||||
} else if (event.touches.length === 1) {
|
||||
isDragging = true;
|
||||
const touch = event.touches[0];
|
||||
dragStart.x = touch.clientX;
|
||||
dragStart.y = touch.clientY;
|
||||
dragStartOffset.x = canvasOffset.x;
|
||||
dragStartOffset.y = canvasOffset.y;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCanvasTouchMove(event: TouchEvent) {
|
||||
if (event.touches.length === 1 && isDragging && !isPinching) {
|
||||
const touch = event.touches[0];
|
||||
const deltaX = touch.clientX - dragStart.x;
|
||||
const deltaY = touch.clientY - dragStart.y;
|
||||
canvasOffset.x = dragStartOffset.x + deltaX;
|
||||
canvasOffset.y = dragStartOffset.y + deltaY;
|
||||
} else if (event.touches.length === 2 && isPinching) {
|
||||
const currentDistance = getTouchDistance(event.touches);
|
||||
const zoomFactor = currentDistance / lastPinchDistance;
|
||||
const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const pinchCenterX = pinchCenter.x - rect.left;
|
||||
const pinchCenterY = pinchCenter.y - rect.top;
|
||||
|
||||
const canvasCenterX = (pinchCenterX - canvasOffset.x) / canvasZoom;
|
||||
const canvasCenterY = (pinchCenterY - canvasOffset.y) / canvasZoom;
|
||||
|
||||
canvasZoom = newZoom;
|
||||
|
||||
canvasOffset.x = pinchCenterX - canvasCenterX * canvasZoom;
|
||||
canvasOffset.y = pinchCenterY - canvasCenterY * canvasZoom;
|
||||
|
||||
lastPinchDistance = currentDistance;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCanvasTouchEnd(event: TouchEvent) {
|
||||
if (event.touches.length < 2) isPinching = false;
|
||||
if (event.touches.length < 1) isDragging = false;
|
||||
}
|
||||
|
||||
function addStickyNote() {
|
||||
const randomColor = noteColors[Math.floor(Math.random() * noteColors.length)];
|
||||
const newNote: StickyNoteData = {
|
||||
id: nextNoteId++,
|
||||
text: '',
|
||||
x: (400 - canvasOffset.x) / canvasZoom,
|
||||
y: (200 - canvasOffset.y) / canvasZoom,
|
||||
zIndex: ++maxZIndex,
|
||||
color: randomColor
|
||||
};
|
||||
stickyNotes.push(newNote);
|
||||
socket.emit('sticky-note-add', { room, note: newNote });
|
||||
}
|
||||
|
||||
function updateStickyNote(id: number, text: string) {
|
||||
const note = stickyNotes.find(n => n.id === id);
|
||||
if (note) {
|
||||
note.text = text;
|
||||
socket.emit('sticky-note-update', { room, id, text });
|
||||
}
|
||||
}
|
||||
|
||||
function moveStickyNote(id: number, x: number, y: number) {
|
||||
const note = stickyNotes.find(n => n.id === id);
|
||||
if (note) {
|
||||
note.x = x;
|
||||
note.y = y;
|
||||
note.zIndex = ++maxZIndex;
|
||||
const now = Date.now();
|
||||
if (now - lastEmitTime > 16) { // Throttle to ~60fps
|
||||
socket.emit('sticky-note-move', { room, id, x, y, zIndex: note.zIndex });
|
||||
lastEmitTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteStickyNote(id: number) {
|
||||
const index = stickyNotes.findIndex(n => n.id === id);
|
||||
if (index >= 0) {
|
||||
stickyNotes.splice(index, 1);
|
||||
socket.emit('sticky-note-delete', { room, id });
|
||||
}
|
||||
}
|
||||
|
||||
function exportCanvas() {
|
||||
const canvasState = {
|
||||
selectedDeck,
|
||||
shuffleMode,
|
||||
cards: cards.map(card => ({...card})),
|
||||
stickyNotes: stickyNotes.map(note => ({...note})),
|
||||
deckPosition,
|
||||
maxZIndex,
|
||||
nextNoteId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(canvasState, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `canvas-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function restoreCanvasState(state: any) {
|
||||
selectedDeck = state.selectedDeck ?? 0;
|
||||
shuffleMode = state.shuffleMode ?? 'normal';
|
||||
cards = state.cards ?? [];
|
||||
stickyNotes = state.stickyNotes ?? [];
|
||||
deckPosition = state.deckPosition ?? { x: 50, y: 300 };
|
||||
maxZIndex = state.maxZIndex ?? (cards.length + (stickyNotes.length || 0));
|
||||
nextNoteId = state.nextNoteId ?? 1;
|
||||
}
|
||||
|
||||
function importCanvas() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const canvasState = JSON.parse(e.target?.result as string);
|
||||
|
||||
if (!canvasState.cards || !Array.isArray(canvasState.cards)) {
|
||||
alert('Invalid canvas file format');
|
||||
return;
|
||||
}
|
||||
|
||||
restoreCanvasState(canvasState);
|
||||
|
||||
// Broadcast imported state to others
|
||||
socket.emit('import-canvas', { room, state: canvasState });
|
||||
|
||||
} catch (error) {
|
||||
alert('Error reading canvas file: ' + error);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (socket) {
|
||||
socket.emit('shuffle-mode-change', { room, shuffleMode });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="game-container">
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="deck-select">Deck:</label>
|
||||
<select id="deck-select" bind:value={selectedDeck} onchange={handleDeckChange}>
|
||||
{#each deckConfigs as config, index}
|
||||
<option value={index}>{config.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if selectedDeck === 1}
|
||||
<div class="control-group">
|
||||
<label for="shuffle-mode">Shuffle:</label>
|
||||
<select id="shuffle-mode" bind:value={shuffleMode}>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="shuffle-only-risks">Only Risks</option>
|
||||
<option value="shuffle-per-category">Per Category</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button onclick={collectToDeck}>Collect to Deck</button>
|
||||
<button onclick={shuffleDeck}>Shuffle Deck</button>
|
||||
<button onclick={dealCards}>Deal Cards</button>
|
||||
<button onclick={flipAllCards}>Flip All</button>
|
||||
<button onclick={addStickyNote}>Add Note</button>
|
||||
<button onclick={exportCanvas}>Export</button>
|
||||
<button onclick={importCanvas}>Import</button>
|
||||
</div>
|
||||
|
||||
<div class="game-area">
|
||||
<div
|
||||
class="game-area"
|
||||
onwheel={handleCanvasWheel}
|
||||
onmousedown={handleCanvasMouseDown}
|
||||
onmousemove={handleCanvasMouseMove}
|
||||
onmouseup={handleCanvasMouseUp}
|
||||
oncontextmenu={handleCanvasContextMenu}
|
||||
ontouchstart={handleCanvasTouchStart}
|
||||
ontouchmove={handleCanvasTouchMove}
|
||||
ontouchend={handleCanvasTouchEnd}
|
||||
>
|
||||
<div
|
||||
class="canvas-content"
|
||||
style="transform: translate({canvasOffset.x}px, {canvasOffset.y}px) scale({canvasZoom});"
|
||||
>
|
||||
{#each cards as card (card.id)}
|
||||
<Card
|
||||
{...card}
|
||||
backImage={backImage}
|
||||
backImage={card.isRiskCategory ? 'risks/risikocat_back.png' : backImage}
|
||||
canvasOffset={canvasOffset}
|
||||
canvasZoom={canvasZoom}
|
||||
bind:flipped={card.flipped}
|
||||
bind:x={card.x}
|
||||
bind:y={card.y}
|
||||
@ -139,6 +769,21 @@
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each stickyNotes as note (note.id)}
|
||||
<StickyNote
|
||||
{...note}
|
||||
canvasOffset={canvasOffset}
|
||||
canvasZoom={canvasZoom}
|
||||
bind:text={note.text}
|
||||
bind:x={note.x}
|
||||
bind:y={note.y}
|
||||
bind:zIndex={note.zIndex}
|
||||
onMove={moveStickyNote}
|
||||
onUpdate={updateStickyNote}
|
||||
onDelete={deleteStickyNote}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="deck-area"
|
||||
style="left: {deckPosition.x}px; top: {deckPosition.y}px;"
|
||||
@ -147,6 +792,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game-container {
|
||||
@ -164,6 +810,33 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px 12px;
|
||||
border-radius: 27px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
@ -187,6 +860,42 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-area:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
position: relative;
|
||||
width: 500vw;
|
||||
height: 500vh;
|
||||
min-width: 500vw;
|
||||
min-height: 500vh;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.deck-area {
|
||||
|
||||
BIN
static/risks/altsysteme_und_altlasten1.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
static/risks/altsysteme_und_altlasten2.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
static/risks/altsysteme_und_altlasten3.png
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
static/risks/altsysteme_und_altlasten4.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
static/risks/altsysteme_und_altlasten5.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
static/risks/betrieb_und_deployment1.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
static/risks/betrieb_und_deployment2.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
static/risks/betrieb_und_deployment3.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
static/risks/betrieb_und_deployment4.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
static/risks/betrieb_und_deployment5.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
static/risks/fremdsysteme_und_plattformen1.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
static/risks/fremdsysteme_und_plattformen2.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
static/risks/fremdsysteme_und_plattformen3.png
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
static/risks/fremdsysteme_und_plattformen4.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
static/risks/fremdsysteme_und_plattformen5.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
static/risks/kompetenz_und_erfahrung1.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
static/risks/kompetenz_und_erfahrung2.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
static/risks/kompetenz_und_erfahrung3.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
static/risks/kompetenz_und_erfahrung4.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
static/risks/kompetenz_und_erfahrung5.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
static/risks/orga_und_prozesse1.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
static/risks/orga_und_prozesse2.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
static/risks/orga_und_prozesse3.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
static/risks/orga_und_prozesse4.png
Normal file
|
After Width: | Height: | Size: 575 KiB |
BIN
static/risks/orga_und_prozesse5.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
static/risks/risiko_back.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
static/risks/risikocat_back.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
static/risks/softwareloesung1.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
static/risks/softwareloesung2.png
Normal file
|
After Width: | Height: | Size: 492 KiB |
BIN
static/risks/softwareloesung3.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
static/risks/softwareloesung4.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
static/risks/softwareloesung5.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
static/risks/weiche_faktoren1.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
static/risks/weiche_faktoren2.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
static/risks/weiche_faktoren3.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
static/risks/weiche_faktoren4.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
static/risks/weiche_faktoren5.png
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
static/risks/zielsetzung1.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
static/risks/zielsetzung2.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
static/risks/zielsetzung3.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
static/risks/zielsetzung4.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
static/risks/zielsetzung5.png
Normal file
|
After Width: | Height: | Size: 288 KiB |