aboutsummaryrefslogtreecommitdiff
path: root/frontend/ts/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/ts/src/pages')
-rw-r--r--frontend/ts/src/pages/layout.ts37
-rw-r--r--frontend/ts/src/pages/login.ts67
-rw-r--r--frontend/ts/src/pages/map.ts231
-rw-r--r--frontend/ts/src/pages/map/footer.ts169
-rw-r--r--frontend/ts/src/pages/map/marker.ts129
-rw-r--r--frontend/ts/src/pages/map/markerForm.ts172
-rw-r--r--frontend/ts/src/pages/maps.ts94
-rw-r--r--frontend/ts/src/pages/notFound.ts5
8 files changed, 904 insertions, 0 deletions
diff --git a/frontend/ts/src/pages/layout.ts b/frontend/ts/src/pages/layout.ts
new file mode 100644
index 0000000..3140c17
--- /dev/null
+++ b/frontend/ts/src/pages/layout.ts
@@ -0,0 +1,37 @@
+import { h, Html } from 'lib/rx'
+import * as rx from 'lib/rx'
+import * as request from 'request'
+import * as route from 'route'
+import { User } from 'models/user'
+import * as modal from 'ui/modal'
+
+export function view(user: User, children: Html): Html {
+ return [
+ h('header',
+ h('a', { href: '/' }, 'Maps'),
+ h('div',
+ user.name,
+ rx.withState<string | undefined>(undefined, logoutError => [
+ h('button',
+ {
+ className: 'g-Logout__Button',
+ onclick: (event: Event) => {
+ request
+ .post_('/api/logout')
+ .then(_ => window.location.href = '')
+ .catch(({ message }) => logoutError.update(_ => message))
+ }
+ },
+ '(Déconnexion)'
+ ),
+ logoutError.map(err => err && modal.error({
+ header: 'Erreur lors de la déconnexion',
+ message: err,
+ onClose: () => logoutError.update(_ => undefined)
+ }))
+ ])
+ )
+ ),
+ h('main', children)
+ ]
+}
diff --git a/frontend/ts/src/pages/login.ts b/frontend/ts/src/pages/login.ts
new file mode 100644
index 0000000..32da92a
--- /dev/null
+++ b/frontend/ts/src/pages/login.ts
@@ -0,0 +1,67 @@
+import { h, withState3, Html } from 'lib/rx'
+import * as request from 'request'
+import * as route from 'route'
+import * as form from 'lib/form'
+import * as L from 'lib/loadable'
+import { User } from 'models/user'
+
+interface Params {
+ updateUser: (user: User) => void
+}
+
+export function view({ updateUser }: Params): Html {
+ return withState3<string, string, L.Loadable<void>>(
+ ['', '', L.init],
+ (emailVar, passwordVar, requestVar) => {
+ return h('main',
+ h('form',
+ {
+ className: 'g-Login',
+ onsubmit: emailVar.flatMap(email => passwordVar.map(password => (event: Event) => {
+ event.preventDefault()
+ const payload = { email, password }
+ requestVar.update(_ => L.loading)
+ request
+ .post<User>('/api/login', JSON.stringify(payload))
+ .then(user => {
+ requestVar.update(_ => L.loaded(undefined))
+ updateUser(user)
+ route.push(route.maps)
+ })
+ .catch(({ message }) => {
+ requestVar.update(_ => L.failure(message))
+ const passwordInput = document.querySelector('input[type=password]') as HTMLInputElement
+ passwordInput.select()
+ })
+ }))
+ },
+ h('h1', { className: 'g-Login__Title' }, 'Maps'),
+ form.input({
+ label: 'Identifiant',
+ required: true,
+ select: true,
+ onUpdate: value => {
+ emailVar.update(_ => value)
+ requestVar.update(_ => L.init)
+ }
+ }),
+ form.input({
+ label: 'Mot de passe',
+ type: 'password',
+ required: true,
+ onUpdate: value => {
+ passwordVar.update(_ => value)
+ requestVar.update(_ => L.init)
+ }
+ }),
+ form.error(requestVar),
+ form.submit({
+ className: 'g-Button--FullWidth',
+ label: 'Connexion',
+ requestVar
+ })
+ )
+ )
+ }
+ )
+}
diff --git a/frontend/ts/src/pages/map.ts b/frontend/ts/src/pages/map.ts
new file mode 100644
index 0000000..b445f63
--- /dev/null
+++ b/frontend/ts/src/pages/map.ts
@@ -0,0 +1,231 @@
+import { h, Html } from 'lib/rx'
+import * as rx from 'lib/rx'
+import * as request from 'request'
+import * as modal from 'ui/modal'
+import * as route from 'route'
+import { Map } from 'models/map'
+import * as markerModel from 'models/marker'
+import * as M from 'lib/leaflet'
+import * as footer from 'pages/map/footer'
+import * as markerView from 'pages/map/marker'
+import * as markerForm from 'pages/map/markerForm'
+import * as L from 'lib/loadable'
+import * as layout from 'ui/layout'
+
+export function view(id: string): Html {
+ return rx.withState2<L.Loadable<Map>, L.Loadable<markerModel.Map>>(
+ [L.loading, L.loading],
+ (mapVar, markersVar) => {
+ request
+ .get<Map>(`/api/maps/${id}`)
+ .then(res => mapVar.update(_ => L.loaded(res)))
+ .catch(({ message }) => mapVar.update(_ => L.failure(message)))
+
+ request
+ .get<Array<markerModel.Marker>>(`/api/markers?map=${id}`)
+ .then(res => markersVar.update(_ => L.loaded(markerModel.toMap(res))))
+ .catch(({ message }) => markersVar.update(_ => L.failure(message)))
+
+ return rx.map2(
+ [mapVar, markersVar],
+ (map, markers) => {
+ return layout.loadable(
+ L.map2([map, markers], (map, markers) => ({ map, markers })),
+ ({map, markers}) => h('div',
+ { className: 'g-Map' },
+ withMap(map => mapView(id, map, markers)),
+ footer.view(map)
+ )
+ )
+ }
+ )
+ }
+ )
+}
+
+function withMap(f: (map: M.Map) => Html): Html {
+ return rx.withState<M.Map | null>(null, mapVar => [
+ h('div',
+ {
+ onmount: (element: HTMLElement) => {
+ const map = M.map(element, {
+ center: [30, 10],
+ zoom: 2.5,
+ attributionControl: false
+ })
+
+ map.addLayer(M.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png'))
+
+ mapVar.update(_ => map)
+ }
+ }
+ ),
+ mapVar.map(map => map && f(map))
+ ])
+}
+
+interface MarkerContext {
+ markerId: string
+ markerElem: M.FeatureGroup
+}
+
+interface ErrorModal {
+ title: string
+ message: string
+}
+
+function mapView(map_id: string, map: M.Map, markers: markerModel.Map): Html {
+ let lastUserAdded: markerModel.Marker | undefined
+
+ return rx.withState3<
+ M.Pos | undefined,
+ MarkerContext | undefined,
+ ErrorModal | undefined
+ >(
+ [undefined, undefined, undefined],
+ (addModalVar, updateModalVar, errorModalVar) => {
+ map.addEventListener('click', (event: M.MapEvent) => addModalVar.update(_ => event.latlng))
+
+ const onClick = (m: MarkerContext) => updateModalVar.update(_ => m)
+
+ const onMoveError = (message: string) => {
+ errorModalVar.update(_ => ({
+ title: 'Erreur lors du déplacement du marqueur.',
+ message
+ }))
+ }
+
+ const elements = M.featureGroup()
+ Object.values(markers).forEach(marker => {
+ const elem = addMarker({ marker, markers, map, onClick, onMoveError })
+ elements.addLayer(elem)
+ })
+
+ // Fit bounds to elements
+ if (elements.getLayers().length > 0) {
+ map.fitBounds(elements.getBounds(), { padding: [ 100, 100 ] })
+ }
+
+ return [
+ addModalVar.map(pos => {
+ if (pos !== undefined) {
+ return markerForm.view({
+ lat: pos.lat,
+ lng: pos.lng,
+ colors: markerColors(markers),
+ lastUserAdded,
+ label: 'Ajouter',
+ req: (body: string) => request.post<markerModel.Marker>(
+ `/api/markers?map=${map_id}`,
+ body
+ ),
+ onSuccess: marker => {
+ addMarker({ marker, markers, map, onClick, onMoveError })
+ addModalVar.update(_ => undefined)
+ markers[marker.id] = marker
+ lastUserAdded = marker
+ },
+ onClose: () => addModalVar.update(_ => undefined)
+ })
+ }
+ }),
+ updateModalVar.map(m => {
+ if (m !== undefined) {
+ const { markerId, markerElem } = m
+ const marker = markers[markerId]
+ return markerForm.view({
+ lat: marker.lat,
+ lng: marker.lng,
+ marker,
+ colors: markerColors(markers),
+ label: 'Modifier',
+ req: (body: string) => request.put<markerModel.Marker>(
+ `/api/markers/${marker.id}`,
+ body
+ ),
+ onSuccess: marker => {
+ map.removeLayer(markerElem)
+ addMarker({ marker, markers, map, onClick, onMoveError })
+ updateModalVar.update(_ => undefined)
+ markers[marker.id] = marker
+ lastUserAdded = marker
+ },
+ onClose: () => updateModalVar.update(_ => undefined),
+ onDeleteMarker: () => {
+ map.removeLayer(markerElem)
+ delete markers[marker.id]
+ updateModalVar.update(_ => undefined)
+ }
+ })
+ }
+ }),
+ errorModalVar.map(err => err && modal.error({
+ header: err.title,
+ message: err.message,
+ onClose: () => errorModalVar.update(_ => undefined)
+ }))
+ ]
+ }
+ )
+}
+
+interface AddMarkerParams {
+ markers: markerModel.Map
+ marker: markerModel.Marker
+ map: M.Map
+ onClick: (m: MarkerContext) => void
+ onMoveError: (message: string) => void
+}
+
+function addMarker({ markers, marker, map, onClick, onMoveError }: AddMarkerParams): M.FeatureGroup {
+ const elem = markerView.create({
+ marker,
+ onMove: markerElem => {
+ const pos = markerElem.getLatLng()
+ const newMarker = structuredClone(marker)
+ newMarker.lat = pos.lat
+ newMarker.lng = pos.lng
+ updateMarker(newMarker)
+ .then(m => markers[marker.id] = m)
+ .catch(({ message }) => {
+ markerElem.setLatLng({ lat: marker.lat, lng: marker.lng })
+ onMoveError(message)
+ })
+ },
+ onClick: (markerElem: M.FeatureGroup) => onClick({ markerId: marker.id, markerElem })
+ })
+
+ map.addLayer(elem)
+
+ return elem
+}
+
+function updateMarker(m: markerModel.Marker): Promise<markerModel.Marker> {
+ const payload = {
+ lat: m.lat,
+ lng: m.lng,
+ color: m.color,
+ name: m.name,
+ description: m.description,
+ icon: m.icon,
+ radius: m.radius
+ }
+
+ return request.put(`/api/markers/${m.id}`, JSON.stringify(payload))
+}
+
+function markerColors(markers: markerModel.Map): Array<string> {
+ const frequencies: { [key: string]: number } = {}
+ Object.values(markers).forEach(m => {
+ if (m.color in frequencies) {
+ frequencies[m.color] += 1
+ } else {
+ frequencies[m.color] = 1
+ }
+ })
+ let colors = Object.keys(frequencies).map(key => [key, frequencies[key]])
+ // @ts-ignore
+ colors.sort((a, b) => b[1] - a[1])
+ // @ts-ignore
+ return colors.map(c => c[0])
+}
diff --git a/frontend/ts/src/pages/map/footer.ts b/frontend/ts/src/pages/map/footer.ts
new file mode 100644
index 0000000..945bfd4
--- /dev/null
+++ b/frontend/ts/src/pages/map/footer.ts
@@ -0,0 +1,169 @@
+import { h, Html } from 'lib/rx'
+import * as rx from 'lib/rx'
+import * as request from 'request'
+import * as modal from 'ui/modal'
+import * as route from 'route'
+import { Map } from 'models/map'
+import * as form from 'lib/form'
+import * as L from 'lib/loadable'
+
+export function view(mapInit: Map): Html {
+ return h('footer',
+ { className: 'g-Map__Footer' },
+ rx.withState<Map>(mapInit, mapVar =>
+ mapVar.map(map => [
+ map.name,
+ h('div',
+ { className: 'g-Map__FooterButtons' },
+ viewRenameButton(map, (map: Map) => mapVar.update(_ => map)),
+ viewDeleteButton(map)
+ )
+ ])
+ )
+ )
+}
+
+// Rename modal
+
+function viewRenameButton(map: Map, onUpdate: (m: Map) => void): Html {
+ return rx.withState<boolean>(false, showVar => [
+ h('button',
+ { className: 'g-Button',
+ onclick: (event: Event) => showVar.update(_ => true)
+ },
+ 'Renommer'
+ ),
+ showVar.map(show => {
+ if (show) {
+ return renameModal({
+ map,
+ onUpdate,
+ onClose: () => showVar.update(_ => false)
+ })
+ }
+ })
+ ])
+}
+
+interface RenameParams {
+ map: Map,
+ onUpdate: (m: Map) => void,
+ onClose: () => void,
+}
+
+function renameModal({ map, onUpdate, onClose }: RenameParams): Html {
+ const initName = map.name
+
+ return modal.view({
+ header: `Renommer la carte ${map.name}`,
+ body: rx.withState2<string, L.Loadable<void>>([initName, L.init], (nameVar, requestVar) =>
+ h('form',
+ {
+ onsubmit: nameVar.map(name => (event: Event) => {
+ event.preventDefault()
+ requestVar.update(_ => L.loading)
+
+ request
+ .put<Map>(`/api/maps/${map.id}`, JSON.stringify({ name }))
+ .then(map => {
+ requestVar.update(_ => L.loaded(undefined))
+ onUpdate(map)
+ })
+ .catch(({ message }) => requestVar.update(_ => L.failure(message)))
+ })
+ },
+ form.input({
+ label: 'Nom',
+ initValue: initName,
+ select: true,
+ required: true,
+ onUpdate: value => {
+ nameVar.update(_ => value)
+ requestVar.update(_ => L.init)
+ }
+ }),
+ form.error(requestVar),
+ form.submit({
+ label: 'Renommer',
+ className: 'g-Button--FullWidth',
+ requestVar
+ })
+ )
+ ),
+ onClose: onClose
+ })
+}
+
+// Delete modal
+
+function viewDeleteButton(map: Map): Html {
+ return rx.withState<boolean>(false, showVar => [
+ h('button',
+ { className: 'g-Button g-Button--Danger',
+ onclick: (event: Event) => showVar.update(_ => true)
+ },
+ 'Supprimer'
+ ),
+ showVar.map(show => {
+ if (show) {
+ return deleteModal({
+ map,
+ onClose: () => showVar.update(_ => false)
+ })
+ }
+ })
+ ])
+}
+
+interface DeleteParams {
+ map: Map,
+ onClose: () => void,
+}
+
+function deleteModal({ map, onClose }: DeleteParams): Html {
+ const initName = ''
+
+ return modal.view({
+ className: 'g-Modal--Danger',
+ header: `Supprimer la carte ${map.name} ?`,
+ body: rx.withState2<string, L.Loadable<void>>([initName, L.init], (nameVar, requestVar) =>
+ h('form',
+ {
+ onsubmit: (event: Event) => {
+ event.preventDefault()
+ requestVar.update(_ => L.loading)
+
+ request
+ .del_(`/api/maps/${map.id}`)
+ .then(_ => {
+ requestVar.update(_ => L.loaded(undefined))
+ route.push(route.maps)
+ })
+ .catch(({ message }) => requestVar.update(_ => L.failure(message)))
+ }
+ },
+ h('p',
+ 'Veuillez recopier le nom de la carte pour la supprimer : ',
+ h('b', map.name)
+ ),
+ form.input({
+ label: 'Nom',
+ initValue: initName,
+ select: true,
+ onUpdate: value => {
+ nameVar.update(_ => value)
+ requestVar.update(_ => L.init)
+ }
+ }),
+ form.error(requestVar),
+ form.submit({
+ label: 'Supprimer',
+ className: 'g-Button--Danger g-Button--FullWidth',
+ disabled: nameVar.map(name => name != map.name),
+ requestVar
+ })
+ )
+ ),
+ onClose: onClose
+ })
+}
diff --git a/frontend/ts/src/pages/map/marker.ts b/frontend/ts/src/pages/map/marker.ts
new file mode 100644
index 0000000..b690741
--- /dev/null
+++ b/frontend/ts/src/pages/map/marker.ts
@@ -0,0 +1,129 @@
+import { mount, h, s } from 'lib/rx'
+import * as Color from 'lib/color'
+import * as M from 'lib/leaflet'
+import * as icons from 'lib/icons'
+import * as markerModel from 'models/marker'
+
+interface CreateParams {
+ marker: markerModel.Marker,
+ onMove: (marker: M.Layer) => void,
+ onClick: (markerElem: M.FeatureGroup) => void,
+}
+
+export function create({ marker, onMove, onClick }: CreateParams): M.FeatureGroup {
+ const { lat, lng, color, icon, name, description, radius } = marker
+ const pos = { lat, lng }
+
+ const markerElem = M.marker(pos, {
+ draggable: true,
+ autoPan: true,
+ icon: divIcon({ icon, color, name, description })
+ })
+
+ const circle =
+ radius !== undefined && radius !== 0
+ ? M.circle(pos, { radius, color, fillColor: color })
+ : undefined
+
+ const layer =
+ circle !== undefined
+ ? M.featureGroup([ markerElem, circle ])
+ : M.featureGroup([ markerElem ])
+
+ markerElem.addEventListener('drag', e => {
+ circle && circle.setLatLng(markerElem.getLatLng())
+ })
+
+ markerElem.addEventListener('dragend', () => onMove(markerElem))
+
+ markerElem.addEventListener('click', (e: M.MapEvent) => onClick(layer))
+
+ return layer
+}
+
+interface CreateIconParams {
+ icon?: string,
+ color: string,
+ name?: string,
+ description?: string
+}
+
+function divIcon({ icon, color, name, description }: CreateIconParams): M.Icon {
+ const c = Color.parse(color)
+ const crBlack = Color.contrastRatio({ red: 0, green: 0, blue: 0 }, c)
+ const crWhite = Color.contrastRatio({ red: 255, green: 255, blue: 255 }, c)
+ const textCol = crBlack > crWhite ? 'black' : 'white'
+ const width = 10
+ const height = 15
+ const stroke = 'black'
+ const strokeWidth = 0.6
+ // Triangle
+ const t = [
+ { x: width * 0.15, y: 7.46 },
+ { x: width / 2, y: height },
+ { x: width * 0.85, y: 7.46 }
+ ]
+
+ const html = document.createElement('div')
+ html.className = 'g-Marker'
+ if (description) html.title = description
+
+ mount(html, [
+ s('svg',
+ {
+ viewBox: `0 0 ${width} ${height}`,
+ class: 'g-Marker__Base'
+ },
+ s('circle',
+ {
+ cx: width / 2,
+ cy: width / 2,
+ r: (width - 2 * strokeWidth) / 2,
+ stroke,
+ 'stroke-width': strokeWidth,
+ fill: color
+ }
+ ),
+ s('polygon', { points: `${t[0].x},${t[0].y} ${t[1].x},${t[1].y} ${t[2].x},${t[2].y}`, fill: color }),
+ s('line', {
+ x1: t[0].x,
+ y1: t[0].y,
+ x2: t[1].x,
+ y2: t[1].y,
+ stroke,
+ 'stroke-width': strokeWidth
+ }),
+ s('line', {
+ x1: t[1].x,
+ y1: t[1].y,
+ x2: t[2].x,
+ y2: t[2].y,
+ stroke,
+ 'stroke-width': strokeWidth
+ }),
+ ),
+ icon && icons.svg(icon, {
+ class: 'g-Marker__Icon',
+ style: `fill: ${textCol}; stroke: ${textCol}`
+ }),
+ name && h('div',
+ {
+ className: 'g-Marker__Title',
+ style: `text-shadow: ${textShadow('white', 1, 1)}`
+ },
+ name
+ )
+ ])
+
+ return M.divIcon({
+ className: '',
+ popupAnchor: [ 0, -34 ],
+ html
+ })
+}
+
+function textShadow(color: string, w: number, blurr: number): string {
+ return [[-w, -w], [-w, 0], [-w, w], [0, -w], [0, w], [w, -w], [w, 0], [w, w]]
+ .map(xs => `${color} ${xs[0]}px ${xs[1]}px ${blurr}px`)
+ .join(', ')
+}
diff --git a/frontend/ts/src/pages/map/markerForm.ts b/frontend/ts/src/pages/map/markerForm.ts
new file mode 100644
index 0000000..f04a008
--- /dev/null
+++ b/frontend/ts/src/pages/map/markerForm.ts
@@ -0,0 +1,172 @@
+import { h, Html, Rx } from 'lib/rx'
+import * as rx from 'lib/rx'
+import * as request from 'request'
+import * as modal from 'ui/modal'
+import * as layout from 'ui/layout'
+import * as markerModel from 'models/marker'
+import * as form from 'lib/form'
+import * as icons from 'lib/icons'
+import * as L from 'lib/loadable'
+
+interface MarkerFormParams {
+ lat: number
+ lng: number
+ marker?: markerModel.Marker
+ colors: Array<string>
+ label: string
+ req: (body: string) => Promise<markerModel.Marker>
+ onSuccess: (m: markerModel.Marker) => void
+ onClose: () => void
+ lastUserAdded?: markerModel.Marker
+ onDeleteMarker?: () => void
+}
+
+export function view({ lat, lng, marker, colors, label, req, onSuccess, onClose, lastUserAdded, onDeleteMarker }: MarkerFormParams): Html {
+ const initName = marker?.name || ''
+ const initDescription = marker?.description || ''
+ const initColor = marker?.color || lastUserAdded?.color || (colors.length > 0 ? colors[0] : '#3584e4')
+ const initIcon = marker?.icon || ''
+ const initRadius = marker?.radius || 0
+
+ const isCreation = marker === undefined
+
+ const iconValues: { [key: string]: string } = {}
+ Object.keys(icons.values).forEach(key => iconValues[key] = icons.values[key].name)
+
+ return modal.view({
+ header: marker == undefined ? 'Nouveau marqueur' : 'Modification du marqueur',
+ body: rx.withState7<string, string, string, string, number, L.Loadable<void>, L.Loadable<void>>(
+ [initName, initDescription, initColor, initIcon, initRadius, L.init, L.init],
+ (nameVar, descriptionVar, colorVar, iconVar, radiusVar, updateReqVar, deleteReqVar) =>
+ h('form',
+ {
+ onsubmit: rx.map5([nameVar, descriptionVar, colorVar, iconVar, radiusVar], (name, description, color, icon, radius) =>
+ (event: Event) => {
+ event.preventDefault()
+ updateReqVar.update(_ => L.loading)
+ const payload = { lat, lng, color, name, description, icon, radius }
+
+ req(JSON.stringify(payload))
+ .then((m: markerModel.Marker) => {
+ updateReqVar.update(_ => L.loaded(undefined))
+ onSuccess(m)
+ })
+ .catch(({ message }) => updateReqVar.update(_ => L.failure(message)))
+ }
+ )
+ },
+ form.input({
+ label: 'Nom',
+ select: isCreation,
+ initValue: initName,
+ onUpdate: value => {
+ nameVar.update(_ => value)
+ updateReqVar.update(_ => L.init)
+ deleteReqVar.update(_ => L.init)
+ }
+ }),
+ form.textarea({
+ label: 'Description',
+ initValue: initDescription,
+ onUpdate: value => {
+ descriptionVar.update(_ => value)
+ updateReqVar.update(_ => L.init)
+ deleteReqVar.update(_ => L.init)
+ }
+ }),
+ colorSection({
+ colorRx: colorVar,
+ colors,
+ onUpdateColor: color => {
+ colorVar.update(_ => color)
+ updateReqVar.update(_ => L.init)
+ deleteReqVar.update(_ => L.init)
+ }
+ }),
+ layout.columns([
+ form.select({
+ label: 'Icône',
+ initValue: initIcon,
+ values: iconValues,
+ onUpdate: value => {
+ iconVar.update(_ => value)
+ updateReqVar.update(_ => L.init)
+ deleteReqVar.update(_ => L.init)
+ }
+ }),
+ form.input({
+ type: 'number',
+ label: 'Radius',
+ initValue: initRadius.toString(),
+ onUpdate: value => {
+ radiusVar.update(_ => parseInt(value) || 0)
+ updateReqVar.update(_ => L.init)
+ deleteReqVar.update(_ => L.init)
+ }
+ })
+ ]),
+ form.error(updateReqVar),
+ form.error(deleteReqVar),
+ form.submit({
+ label,
+ className: 'g-Button--FullWidth',
+ requestVar: updateReqVar,
+ disabled: deleteReqVar.map(l => l != L.init)
+ }),
+ marker !== undefined && onDeleteMarker !== undefined && form.button({
+ style: 'margin-top: 1rem',
+ label: 'Supprimer',
+ className: 'g-Button--FullWidth g-Button--Danger',
+ requestVar: deleteReqVar,
+ disabled: updateReqVar.map(l => l != L.init),
+ onClick: () => {
+ deleteReqVar.update(_ => L.loading)
+ request
+ .del_(`/api/markers/${marker.id}`)
+ .then(_ => onDeleteMarker())
+ .catch(({ message }) => updateReqVar.update(_ => L.failure(message)))
+ }
+ })
+ )
+ ),
+ onClose: onClose
+ })
+}
+
+interface ColorSectionParams {
+ colorRx: Rx<string>,
+ colors: Array<string>,
+ onUpdateColor: (color: string) => void,
+}
+
+function colorSection({ colorRx, colors, onUpdateColor }: ColorSectionParams): Html {
+ return h('label',
+ { className: 'g-Label' },
+ 'Couleur',
+ h('div',
+ { className: 'g-ColorLine' },
+ h('input',
+ {
+ type: 'color',
+ className: 'g-Input',
+ oninput: (event: Event) => onUpdateColor((event.target as HTMLInputElement).value),
+ value: colorRx
+ }
+ ),
+ h('div',
+ { className: 'g-ColorButtons' },
+ colors.map(c =>
+ h('input',
+ {
+ type: 'button',
+ className: 'g-ColorButton',
+ style: `background-color: ${c}`,
+ onclick: (event: Event) => onUpdateColor(c)
+ }
+ )
+ )
+ )
+ )
+ )
+
+}
diff --git a/frontend/ts/src/pages/maps.ts b/frontend/ts/src/pages/maps.ts
new file mode 100644
index 0000000..ef5b785
--- /dev/null
+++ b/frontend/ts/src/pages/maps.ts
@@ -0,0 +1,94 @@
+import { h, withState, withState2, Html } from 'lib/rx'
+import * as request from 'request'
+import * as modal from 'ui/modal'
+import * as route from 'route'
+import { Map } from 'models/map'
+import * as form from 'lib/form'
+import * as L from 'lib/loadable'
+import * as layout from 'ui/layout'
+
+export function view(): Html {
+ return withState<L.Loadable<Array<Map>>>(L.loading, mapsVar => {
+ request
+ .get<Array<Map>>('/api/maps')
+ .then(res => {
+ res.sort((a, b) => a.name.localeCompare(b.name))
+ mapsVar.update(_ => L.loaded(res))
+ })
+ .catch(({ message }) => L.failure(message))
+
+ return h('div',
+ { className: 'g-Maps' },
+ withState<boolean>(false, showVar => [
+ h('button',
+ { className: 'g-Button g-Button--Primary',
+ onclick: (event: Event) => showVar.update(_ => true)
+ },
+ 'Nouvelle carte'
+ ),
+ showVar.map(show => {
+ if (show) {
+ return createModal({
+ onClose: () => showVar.update(_ => false)
+ })
+ }
+ })
+ ]),
+ mapsVar.map(loadable => layout.loadable(loadable, maps => h('ul', maps.map(viewMap))))
+ )
+ })
+}
+
+function viewMap(m: Map): Html {
+ const url = route.toString(route.map({ id: m.id }))
+
+ return h('li',
+ h('a', { href: url }, m.name)
+ )
+}
+
+// Create map
+
+interface CreateParams {
+ onClose: () => void,
+}
+
+function createModal({ onClose }: CreateParams): Html {
+ return modal.view({
+ header: 'Nouvelle carte',
+ body: withState2<string, L.Loadable<void>>(['', L.init], (nameVar, requestVar) =>
+ h('form',
+ {
+ onsubmit: nameVar.map(name => (event: Event) => {
+ event.preventDefault()
+ requestVar.update(_ => L.loading)
+
+ request
+ .post<Map>('/api/maps', JSON.stringify({ name }))
+ .then(map => {
+ requestVar.update(_ => L.loaded(undefined))
+ route.push(route.map({ id: map.id }))
+ })
+ .catch(({ message }) => requestVar.update(_ => L.failure(message)))
+ })
+ },
+ form.input({
+ label: 'Nom',
+ select: true,
+ onUpdate: value => {
+ nameVar.update(_ => value)
+ requestVar.update(_ => L.init)
+ },
+ required: true
+ }),
+ form.error(requestVar),
+ form.submit({
+ label: 'Créer',
+ className: 'g-Button--FullWidth',
+ requestVar
+ })
+ )
+ ),
+ onClose: onClose
+ })
+}
diff --git a/frontend/ts/src/pages/notFound.ts b/frontend/ts/src/pages/notFound.ts
new file mode 100644
index 0000000..acb4805
--- /dev/null
+++ b/frontend/ts/src/pages/notFound.ts
@@ -0,0 +1,5 @@
+import { h, withState, Html } from 'lib/rx'
+
+export function view(): Html {
+ return 'Page introuvable…';
+}