diff options
Diffstat (limited to 'frontend/ts/src/pages')
-rw-r--r-- | frontend/ts/src/pages/layout.ts | 37 | ||||
-rw-r--r-- | frontend/ts/src/pages/login.ts | 67 | ||||
-rw-r--r-- | frontend/ts/src/pages/map.ts | 231 | ||||
-rw-r--r-- | frontend/ts/src/pages/map/footer.ts | 169 | ||||
-rw-r--r-- | frontend/ts/src/pages/map/marker.ts | 129 | ||||
-rw-r--r-- | frontend/ts/src/pages/map/markerForm.ts | 172 | ||||
-rw-r--r-- | frontend/ts/src/pages/maps.ts | 94 | ||||
-rw-r--r-- | frontend/ts/src/pages/notFound.ts | 5 |
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…'; +} |