diff --git a/traque-front/Dockerfile b/traque-front/Dockerfile index c5ddea3..187bfdf 100644 --- a/traque-front/Dockerfile +++ b/traque-front/Dockerfile @@ -1,57 +1,18 @@ # Use Node 22 alpine as parent image FROM node:22-alpine AS base -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat WORKDIR /app +RUN apk add --no-cache libc6-compat + COPY package.json package-lock.json* ./ -RUN npm ci +RUN npm install - -# Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules COPY . . -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during the build. +ENV NODE_ENV development ENV NEXT_TELEMETRY_DISABLED 1 -RUN npm run build - -# Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. -ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public - -# Set the correct permission for prerender cache -RUN mkdir .next -RUN chown nextjs:nodejs .next - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs - EXPOSE 3000 -ENV PORT 3000 - -# server.js is created by next build from the standalone output -# https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD HOSTNAME="0.0.0.0" node server.js +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/traque-front/app/admin/layout.js b/traque-front/app/admin/layout.js index 6c620ca..cce9e8a 100644 --- a/traque-front/app/admin/layout.js +++ b/traque-front/app/admin/layout.js @@ -6,17 +6,8 @@ export default function AdminLayout({ children }) { return ( -
-
-
    -
  • Admin
  • -
  • Teams
  • -
  • Parameters
  • -
-
-
- {children} -
+
+ {children}
diff --git a/traque-front/app/admin/page.js b/traque-front/app/admin/page.js index 4f9fb58..b4b4787 100644 --- a/traque-front/app/admin/page.js +++ b/traque-front/app/admin/page.js @@ -1,36 +1,111 @@ "use client"; -import TeamReady from "@/components/admin/teamReady"; -import { BlueButton, GreenButton, RedButton } from "@/components/util/button"; import { useAdminConnexion } from "@/context/adminConnexionContext"; import useAdmin from "@/hook/useAdmin"; -import { GameState } from "@/util/gameState"; import dynamic from "next/dynamic"; +import TeamList from '@/components/admin/teamList'; +import React, { useState } from 'react' +import TeamAddForm from '@/components/admin/teamAdd'; +import Link from "next/link"; +import TeamInformation from "@/components/admin/teamInformation"; // Imported at runtime and not at compile time const LiveMap = dynamic(() => import('@/components/admin/liveMap'), { ssr: false }); export default function AdminPage() { const { useProtect } = useAdminConnexion(); - const { gameState, changeState } = useAdmin(); - + const [selectedTeamId, setSelectedTeamId] = useState(null); + const { addTeam, gameState, changeState, teams } = useAdmin(); useProtect(); return ( -
+
-
-

Game state

- Current : {gameState} -
- changeState(GameState.SETUP)}>Reset game - changeState(GameState.PLACEMENT)}>Start placement - changeState(GameState.PLAYING)}>Start game +
+

Page Principale

+
+
+
+

Contrôle

+
+
+ + + + + + + +
+
+
+
+

Équipes

+
+
+
- {gameState == GameState.PLACEMENT && }
-
- +
+
+ +
+
+ + + + + + + +
) diff --git a/traque-front/app/admin/parameters/page.js b/traque-front/app/admin/parameters/page.js index 58e8e44..47159ae 100644 --- a/traque-front/app/admin/parameters/page.js +++ b/traque-front/app/admin/parameters/page.js @@ -2,11 +2,15 @@ import GameSettings from "@/components/admin/gameSettings"; import { useAdminConnexion } from "@/context/adminConnexionContext"; import dynamic from "next/dynamic"; +import TeamAddForm from '@/components/admin/teamAdd'; +import useAdmin from '@/hook/useAdmin'; +import Link from "next/link"; // Imported at runtime and not at compile time const ZoneSelector = dynamic(() => import('@/components/admin/polygonZoneMap'), { ssr: false }); export default function AdminPage() { + const { addTeam } = useAdmin(); const { useProtect } = useAdminConnexion(); useProtect(); @@ -14,6 +18,13 @@ export default function AdminPage() { return (
+
+ + + +

Paramètres

+
+
diff --git a/traque-front/components/admin/mapPicker.jsx b/traque-front/components/admin/mapPicker.jsx new file mode 100644 index 0000000..1833362 --- /dev/null +++ b/traque-front/components/admin/mapPicker.jsx @@ -0,0 +1,268 @@ +"use client"; +import { useLocation } from "@/hook/useLocation"; +import { useEffect, useState, useRef, useCallback } from "react"; +import "leaflet/dist/leaflet.css"; +import { Circle, MapContainer, Marker, TileLayer, useMap, Tooltip, Polyline } from "react-leaflet"; +import { useMapCircleDraw } from "@/hook/mapDrawing"; +import useAdmin from "@/hook/useAdmin"; +import { GameState } from "@/util/gameState"; +import L from "leaflet"; +import { createPortal } from "react-dom"; +import TeamInformation from "@/components/admin/teamInformation"; + + +function MapActionControl({ onClick, children }) { + const map = useMap(); + + useEffect(() => { + const controlDiv = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-custom'); + controlDiv.style.background = 'rgba(0,0,0,0.25)'; + controlDiv.style.borderRadius = '9999px'; + controlDiv.style.padding = '8px'; + controlDiv.style.border = 'none'; + controlDiv.title = "Plein écran"; + controlDiv.style.display = "flex"; + controlDiv.style.alignItems = "center"; + controlDiv.style.justifyContent = "center"; + controlDiv.style.width = "56px"; + controlDiv.style.height = "56px"; + controlDiv.onclick = onClick; + controlDiv.innerHTML = ``; + const customControl = L.control({ position: 'bottomleft' }); + customControl.onAdd = () => controlDiv; + customControl.addTo(map); + + return () => { + customControl.remove(); + }; + }, [map, onClick, children]); + + return null; +} + +function LeafletSidePanel({ show, onClose, children }) { + const map = useMap(); + const panelRef = useRef(document.createElement("div")); + + useEffect(() => { + const panelDiv = panelRef.current; + panelDiv.className = "leaflet-control leaflet-control-custom"; + + + const control = L.control({ position: "topright" }); + control.onAdd = () => panelDiv; + if (show) control.addTo(map); + + return () => { + control.remove(); + }; + }, [map, show]); + + if (!show) return null; + + return createPortal( + <> + + {children} + , + panelRef.current + ); +} + +const positionIcon = new L.Icon({ + iconUrl: '/icons/location.png', + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15], + shadowSize: [30, 30], +}); + +function MapPan(props) { + const map = useMap(); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (!initialized && props.center) { + map.flyTo(props.center, props.zoom, { animate: false }); + setInitialized(true) + } + }, [props.center]); + return null; +} + +function MapEventListener({ onClick, onMouseMove }) { + const map = useMap(); + useEffect(() => { + map.on('click', onClick); + return () => { + map.off('click', onClick); + } + }, [onClick]); + useEffect(() => { + map.on('mousemove', onMouseMove); + return () => { + map.off('mousemove', onMouseMove); + } + }); + return null; +} + +const DEFAULT_ZOOM = 14; +export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { + const location = useLocation(Infinity); + const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); + return ( + + + {center && radius && } + {markerPosition && + } + + + ) +} +export const EditMode = { + MIN: 0, + MAX: 1 +} +export function ZonePicker({ minZone, setMinZone, maxZone, setMaxZone, editMode, ...props }) { + const location = useLocation(Infinity); + const { handleClick: maxClick, handleMouseMove: maxHover, center: maxCenter, radius: maxRadius } = useMapCircleDraw(minZone, setMinZone); + const { handleClick: minClick, handleMouseMove: minHover, center: minCenter, radius: minRadius } = useMapCircleDraw(maxZone, setMaxZone); + function handleClick(e) { + if (editMode == EditMode.MAX) { + maxClick(e); + } else { + minClick(e); + } + } + function handleMouseMove(e) { + if (editMode == EditMode.MAX) { + maxHover(e); + } else { + minHover(e); + } + } + + return ( +
+
+ + + {minCenter && minRadius && } + {maxCenter && maxRadius && } + + + +
+ { maxCenter && minCenter && typeof maxCenter.distanceTo === 'function' + && maxRadius + maxCenter.distanceTo(minCenter) >= minRadius + &&

La zone de fin doit être incluse dans celle de départ

} +
+ + ) +} + +export function LiveMap({ selectedTeamId, setSelectedTeamId }) { + const location = useLocation(Infinity); + const [timeLeftNextZone, setTimeLeftNextZone] = useState(null); + const { zone, zoneExtremities, teams, nextZoneDate, isShrinking , getTeam, gameState } = useAdmin(); + + function handleMarkerClick(teamId) { + setSelectedTeamId(teamId); + } + + const mapWrapperRef = useRef(null); + + const handleFullscreen = useCallback(() => { + const el = mapWrapperRef.current; + if (!el) return; + if (!document.fullscreenElement) { + el.requestFullscreen(); + } else { + document.exitFullscreen(); + } + }, []); + + // Remaining time before sending position + useEffect(() => { + const updateTime = () => { + setTimeLeftNextZone(Math.max(0, Math.floor((nextZoneDate - Date.now()) / 1000))); + }; + + updateTime(); + const interval = setInterval(updateTime, 1000); + + return () => clearInterval(interval); + }, [nextZoneDate]); + + function formatTime(time) { + // time is in seconds + if (time < 0) return "00:00"; + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0"); + } + + function Arrow({pos1, pos2}) { + if (pos1 && pos2) { + return ( + + ) + } else { + return null; + } + } + + return ( +
+ {gameState == GameState.PLAYING &&

{`${isShrinking ? "Fin" : "Début"} du rétrécissement de la zone dans : ${formatTime(timeLeftNextZone)}`}

} + + + + {gameState == GameState.PLAYING && zone && } + {gameState == GameState.PLAYING && zoneExtremities && } + {gameState == GameState.PLAYING && zoneExtremities && } + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} + + + )} + + {selectedTeamId && ( + setSelectedTeamId(null)}> + setSelectedTeamId(null)} + /> + + )} + +
+ ) +} \ No newline at end of file diff --git a/traque-front/components/admin/teamAdd.jsx b/traque-front/components/admin/teamAdd.jsx index ce95408..ae14cae 100644 --- a/traque-front/components/admin/teamAdd.jsx +++ b/traque-front/components/admin/teamAdd.jsx @@ -1,6 +1,4 @@ import { useState } from 'react' -import { TextInput } from '../util/textInput' -import { BlueButton } from '../util/button' export default function TeamAddForm({onAddTeam}) { const [teamName, setTeamName] = useState(''); @@ -14,12 +12,12 @@ export default function TeamAddForm({onAddTeam}) { } return ( -
+
- setTeamName(e.target.value)}/> + setTeamName(e.target.value)} type="text" className="block w-full h-full p-4 text-center ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400" />
- + +
); diff --git a/traque-front/components/admin/teamInformation.jsx b/traque-front/components/admin/teamInformation.jsx new file mode 100644 index 0000000..6857fee --- /dev/null +++ b/traque-front/components/admin/teamInformation.jsx @@ -0,0 +1,75 @@ +import useAdmin from "@/hook/useAdmin"; + +function DotLine({ label, value }) { + return ( +
+ {label} + + {value} +
+ ); +} + +// ...existing imports... + +export default function TeamInformation({ selectedTeamId, onClose }) { + const { getTeam, getTeamName, teams } = useAdmin(); + const team = getTeam(selectedTeamId); + + if (!team) return null; + + function formatTime(seconds) { + if (!seconds || seconds < 0) return "—"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; + } + + // Determine image source + const imageSrc = team.photoUrl && team.photoUrl.trim() !== "" + ? team.photoUrl + : "/images/missing_image.jpg"; + + return ( +
+ +
+ {team.captured ? "Capturée" : "En jeu"} +
+
+ {team.name} +
+
+ +
+ + +
+ + +
+ + + + + + + +
+ ); +} \ No newline at end of file diff --git a/traque-front/components/admin/teamList.jsx b/traque-front/components/admin/teamList.jsx index 07312d7..6738dc5 100644 --- a/traque-front/components/admin/teamList.jsx +++ b/traque-front/components/admin/teamList.jsx @@ -1,6 +1,8 @@ import useAdmin from '@/hook/useAdmin'; +import { GameState } from '@/util/gameState'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import React from 'react' +import { useFormStatus } from 'react-dom'; function reorder(list, startIndex, endIndex) { const result = Array.from(list); @@ -9,15 +11,33 @@ function reorder(list, startIndex, endIndex) { return result; }; -function TeamListItem({ team, index, onSelected, itemSelected }) { - const bgColor = team.captured ? " bg-red-400" : " bg-gray-300"; - const border = " border border-4 " + (itemSelected ? "border-black" : team.captured ? "border-red-400" : "border-gray-300"); - const classNames = 'w-full p-3 my-3' + (bgColor) + (border); +const TEAM_STATUS = { + playing: { label: "En jeu", color: "text-custom-green" }, + captured: { label: "Capturée", color: "text-custom-red" }, + outofzone: { label: "Hors zone", color: "text-custom-orange" }, + ready: { label: "Prête", color: "text-custom-blue" }, + notready: { label: "En préparation", color: "text-custom-grey" }, +}; + +function TeamListItem({ team, index, onSelected, itemSelected, gamestate }) { + console.log(gamestate === GameState.PLAYING ? "En jeu" : "En préparation"); + const status = gamestate === GameState.PLAYING ? (team.captured ? TEAM_STATUS.captured : (team.outofzone ? TEAM_STATUS.outofzone : TEAM_STATUS.playing)) : (team.ready ? TEAM_STATUS.ready : TEAM_STATUS.notready); return ( onSelected(team.id)}> {provided => ( -
-

{team.name}

+
+
+ + + + +
+
+

{team.name}

+

+ {status.label} +

+
)} @@ -26,8 +46,7 @@ function TeamListItem({ team, index, onSelected, itemSelected }) { } export default function TeamList({selectedTeamId, onSelected}) { - const {teams, reorderTeams} = useAdmin(); - + const {teams, reorderTeams, gameState} = useAdmin(); function onDragEnd(result) { if (!result.destination) return; if (result.destination.index === result.source.index) return; @@ -42,7 +61,7 @@ export default function TeamList({selectedTeamId, onSelected}) {
    {teams.map((team, i) => (
  • onSelected(team.id)}> - +
  • ))} {provided.placeholder} diff --git a/traque-front/public/icons/arrows.png b/traque-front/public/icons/arrows.png new file mode 100644 index 0000000..b5bcba3 Binary files /dev/null and b/traque-front/public/icons/arrows.png differ diff --git a/traque-front/public/icons/backarrow.png b/traque-front/public/icons/backarrow.png new file mode 100644 index 0000000..806ff29 Binary files /dev/null and b/traque-front/public/icons/backarrow.png differ diff --git a/traque-front/public/icons/fullscreen.png b/traque-front/public/icons/fullscreen.png new file mode 100644 index 0000000..4a7fa7c Binary files /dev/null and b/traque-front/public/icons/fullscreen.png differ diff --git a/traque-front/public/icons/incertitude.png b/traque-front/public/icons/incertitude.png new file mode 100644 index 0000000..dde39bd Binary files /dev/null and b/traque-front/public/icons/incertitude.png differ diff --git a/traque-front/public/icons/informations.png b/traque-front/public/icons/informations.png new file mode 100644 index 0000000..af75c7a Binary files /dev/null and b/traque-front/public/icons/informations.png differ diff --git a/traque-front/public/icons/mapstyle.png b/traque-front/public/icons/mapstyle.png new file mode 100644 index 0000000..28a3e36 Binary files /dev/null and b/traque-front/public/icons/mapstyle.png differ diff --git a/traque-front/public/icons/names.png b/traque-front/public/icons/names.png new file mode 100644 index 0000000..bae60e2 Binary files /dev/null and b/traque-front/public/icons/names.png differ diff --git a/traque-front/public/icons/path.png b/traque-front/public/icons/path.png new file mode 100644 index 0000000..d9bb4d9 Binary files /dev/null and b/traque-front/public/icons/path.png differ diff --git a/traque-front/public/icons/zones.png b/traque-front/public/icons/zones.png new file mode 100644 index 0000000..8895784 Binary files /dev/null and b/traque-front/public/icons/zones.png differ diff --git a/traque-front/tailwind.config.js b/traque-front/tailwind.config.js index a7babb3..584744e 100644 --- a/traque-front/tailwind.config.js +++ b/traque-front/tailwind.config.js @@ -1,5 +1,17 @@ /** @type {import('tailwindcss').Config} */ module.exports = { + theme: { + extend: { + colors: { + 'custom-green': '#19e119', + 'custom-red': '#e11919', + 'custom-orange': '#fa6400', + 'custom-blue': '#1e90ff', + 'custom-grey': '#808080', + 'custom-light-blue': '#80b3ff' + } + } + }, mode: 'jit', content: [ "./pages/**/*.{js,ts,jsx,tsx,mdx}",