diff --git a/src/App.css b/src/App.css index 4bd3ed4..dd1d473 100644 --- a/src/App.css +++ b/src/App.css @@ -101,6 +101,13 @@ main { font-display: swap; } +.yx-hand { + font-family: 'swister', 'Bradley Hand', 'Marker Felt', 'Felt Tip', cursive; + /* font-weight: 500; */ + font-style: normal; + /* font-size: 1.5em; */ +} + #ravland-map .cls-1, #ravland-map .cls-2 { stroke: #000; diff --git a/src/features/journal/journal-slice.ts b/src/features/journal/journal-slice.ts index e69de29..a43c984 100644 --- a/src/features/journal/journal-slice.ts +++ b/src/features/journal/journal-slice.ts @@ -0,0 +1,422 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import { createSelector, createSlice } from '@reduxjs/toolkit' +import { nanoid } from 'nanoid' +import { Err, None, Ok, Option, Result, Some } from 'ts-results' +import { z } from 'zod' +import { + ForbiddenLandsDate, + ForbiddenLandsDateClass, + ForbiddenLandsDateSerializable, + forbiddenLandsDateSerializableSchema, + forbiddenLandsDateStringSchema, + formatForbiddenLandsDate, + parseForbiddenLandsDate, +} from '../../models/forbidden-lands-date.model' +import { + Hex, + HexKey, + hexKeySchema, + isHexKey, +} from '../../pages/places/map.model' +import { createStateStorageWithDateParsing } from '../../store/persist/state-storage' +import { RootState } from '../../store/store' + +import { isNullish, isString, maybe } from '../../functions/utils.functions' +import { selectHex } from '../map/map-slice' + +// * EncounterNote - Notes that detail an encounter + +export const NOTE_MAX_LENGTH = 1000 + +export const explorationNoteSchema = z.object({ + id: z.string(), + hexKey: hexKeySchema, + exploredAt: forbiddenLandsDateSerializableSchema.optional(), + note: z + .object({ + body: z.string().max(NOTE_MAX_LENGTH), + createdAt: forbiddenLandsDateSerializableSchema, + updatedAt: forbiddenLandsDateSerializableSchema, + }) + .optional(), +}) + +export const explorationNoteDateStringSchema = z.object({ + id: z.string(), + hexKey: hexKeySchema, + exploredAt: forbiddenLandsDateStringSchema.or(z.null()), + note: z + .object({ + body: z.string().max(NOTE_MAX_LENGTH), + createdAt: forbiddenLandsDateStringSchema, + updatedAt: forbiddenLandsDateStringSchema, + }) + .or(z.null()), +}) + +export type ExplorationNote = z.infer +export const isValidExplorationNote = (val: unknown): val is ExplorationNote => + explorationNoteSchema.safeParse(val).success + +export type ExplorationNoteDateString = z.infer< + typeof explorationNoteDateStringSchema +> + +export const journalStateSchema = z.object({ + explorationNotes: z.record( + hexKeySchema, + explorationNoteSchema.or(z.undefined()), + ), + settings: z.object({ + useHandwrittenFont: z.boolean(), + }), +}) + +export const journalStateSerializableSchema = z.object({ + explorationNotes: z.record( + hexKeySchema, + explorationNoteDateStringSchema.or(z.undefined()), + ), + settings: z.object({ + useHandwrittenFont: z.boolean(), + }), +}) +export type JournalState = z.infer & { + explorationNotes: Partial> +} +export type JournalStateSerializable = z.infer< + typeof journalStateSerializableSchema +> & { + explorationNotes: Partial> +} + +const journalDeserializer = ( + val: JournalStateSerializable, +): Result => { + const explorationNotes: Partial> = {} + + for (const key of Object.keys(val.explorationNotes)) { + if (!isHexKey(key)) { + return new Err(new Error(`Invalid hex key: ${key}`)) + } + + const explorationNote = val.explorationNotes[key] + + if (isNullish(explorationNote)) { + continue + } + const exploredAt = maybe(explorationNote.exploredAt) + .andThen((date) => { + const parsed = parseForbiddenLandsDate(date) + + return parsed.ok ? Some(parsed.val) : None + }) + .unwrapOr(undefined) + + const note = maybe(explorationNote.note) + .toResult(new Error('Note is nullish')) + .andThen((note) => { + const createdAt = parseForbiddenLandsDate(note.createdAt) + const updatedAt = parseForbiddenLandsDate(note.updatedAt) + + if (createdAt.err || updatedAt.err) { + return new Err( + new Error(`Invalid date string: ${createdAt.val} ${updatedAt.val}`), + ) + } + + const newNote = { + ...note, + createdAt: createdAt.val, + updatedAt: updatedAt.val, + } + + return Ok(newNote) + }) + + if (note.err) { + return new Err(note.val) + } + + explorationNotes[key] = { + ...explorationNote, + exploredAt, + note: note.val, + } + } + + const result: JournalState = { + explorationNotes, + settings: { + useHandwrittenFont: val.settings.useHandwrittenFont, + }, + } + + return Ok(result) +} + +const journalSerializer = ( + val: JournalState, +): Result => { + const explorationNotes: Partial> = + {} + + for (const key of Object.keys(val.explorationNotes)) { + if (!isHexKey(key)) { + return new Err(new Error(`Invalid hex key: ${key}`)) + } + + const explorationNote = val.explorationNotes[key] + + if (isNullish(explorationNote)) { + continue + } + + explorationNotes[key] = { + ...explorationNote, + exploredAt: maybe(explorationNote.exploredAt) + .map(formatForbiddenLandsDate) + .unwrapOr(null), + note: maybe(explorationNote.note) + .map((note) => ({ + ...note, + createdAt: formatForbiddenLandsDate(note.createdAt), + updatedAt: formatForbiddenLandsDate(note.updatedAt), + })) + .unwrapOr(null), + } + } + + const result: JournalStateSerializable = { + explorationNotes, + settings: { + useHandwrittenFont: val.settings.useHandwrittenFont, + }, + } + + return Ok(result) +} + +const JOURNAL_STATE_STORAGE_KEY = 'journalState' +export const localStorageJournalState = createStateStorageWithDateParsing< + JournalState, + JournalStateSerializable +>({ + key: JOURNAL_STATE_STORAGE_KEY, + label: 'JOURNAL', + schema: journalStateSerializableSchema, + serializer: journalSerializer, + deserializer: journalDeserializer, + schemaOutput: journalStateSchema, +}) + +export const initialJournalState: JournalState = { + explorationNotes: { + X25: { + id: nanoid(), + hexKey: 'X25', + exploredAt: { + year: 1165, + month: 1, + day: 1, + monthIndex: 0, + }, + note: { + body: `From state :D Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris.`, + createdAt: { + year: 1165, + month: 1, + day: 1, + monthIndex: 0, + }, + updatedAt: { + year: 1165, + month: 1, + day: 2, + monthIndex: 0, + }, + }, + }, + }, + settings: { + useHandwrittenFont: false, + }, +} +const local = localStorageJournalState.load() +const initialState: JournalState = local.unwrapOr(initialJournalState) + +console.log('[Journal] local', local) +console.log('[Journal] Initial state', initialState) + +const journalSlice = createSlice({ + name: 'journal', + initialState, + reducers: { + toggleUseHandwritten(state) { + state.settings.useHandwrittenFont = !state.settings.useHandwrittenFont + }, + upsertExplorationNote( + state, + action: PayloadAction<{ + hexKey: HexKey + note: string + updatedAt: ForbiddenLandsDateSerializable + }>, + ) { + const { hexKey, note: body, updatedAt } = action.payload + + const stateExplorationNote = state.explorationNotes[hexKey] + + if (!stateExplorationNote) { + state.explorationNotes[hexKey] = { + id: nanoid(), + hexKey, + exploredAt: undefined, + note: { + body, + createdAt: updatedAt, + updatedAt, + }, + } + + return + } + + stateExplorationNote.note = maybe(stateExplorationNote.note) + .map((note) => ({ + ...note, + body, + updatedAt, + })) + .unwrapOr({ + body, + createdAt: updatedAt, + updatedAt, + }) + }, + toggleExploredAt( + state, + action: PayloadAction<{ + hexKey: HexKey + exploredAt: ForbiddenLandsDateSerializable + }>, + ) { + const { hexKey, exploredAt } = action.payload + + const stateExplorationNote = state.explorationNotes[hexKey] + + if (!stateExplorationNote) { + state.explorationNotes[hexKey] = { + id: nanoid(), + hexKey, + exploredAt, + note: { + body: '', + createdAt: exploredAt, + updatedAt: exploredAt, + }, + } + + return + } + + stateExplorationNote.exploredAt = stateExplorationNote.exploredAt + ? undefined + : exploredAt + }, + }, +}) + +export const { toggleUseHandwritten, upsertExplorationNote, toggleExploredAt } = + journalSlice.actions + +export const selectJournal = (state: RootState) => state.journal + +export type ExplorationNoteViewModel = Omit< + ExplorationNote, + 'exploredAt' | 'note' +> & { + exploredAt: Option + note: Option<{ + body: string + createdAt: ForbiddenLandsDate + updatedAt: ForbiddenLandsDate + }> +} + +export const selectAllExplorationNotes = createSelector( + selectJournal, + (journal): ExplorationNoteViewModel[] => { + const notes = Object.values(journal.explorationNotes).map( + (explorationNote) => { + return { + ...explorationNote, + exploredAt: maybe(explorationNote.exploredAt).map((date) => { + return new ForbiddenLandsDateClass(date) + }), + note: maybe(explorationNote.note).map((note) => { + return { + ...note, + createdAt: new ForbiddenLandsDateClass(note.createdAt), + updatedAt: new ForbiddenLandsDateClass(note.updatedAt), + } + }), + } + }, + ) + + return notes + }, +) + +export const selectNote = (hexKey: HexKey) => + createSelector( + [selectHex(hexKey), selectJournal], + (hex, journal): { hex: Option; note: ExplorationNoteViewModel } => { + const explorationNote = journal.explorationNotes[hexKey] + + if (!explorationNote) { + return { + hex, + note: { + id: nanoid(), + hexKey, + exploredAt: None, + note: None, + }, + } + } + + const result: ExplorationNoteViewModel = { + id: explorationNote.id, + hexKey: explorationNote.hexKey, + exploredAt: maybe(explorationNote.exploredAt).map((date) => { + return new ForbiddenLandsDateClass(date) + }), + note: maybe(explorationNote.note).map((note) => { + if (isString(note.createdAt)) { + throw new Error('Invalid date string') + } + + if (isString(note.updatedAt)) { + throw new Error('Invalid date string') + } + + return { + ...note, + createdAt: new ForbiddenLandsDateClass(note.createdAt), + updatedAt: new ForbiddenLandsDateClass(note.updatedAt), + } + }), + } + + return { + hex, + note: result, + } + }, + ) + +export default journalSlice.reducer diff --git a/src/features/map/map-slice.ts b/src/features/map/map-slice.ts index 15239be..c3f64ed 100644 --- a/src/features/map/map-slice.ts +++ b/src/features/map/map-slice.ts @@ -1,17 +1,21 @@ import type { PayloadAction } from '@reduxjs/toolkit' -import { createSlice } from '@reduxjs/toolkit' +import { createSelector, createSlice } from '@reduxjs/toolkit' import { None, Option, Some } from 'ts-results' import { z } from 'zod' import { hexData } from '../../data/hex.data' import { notNullish } from '../../functions/utils.functions' import { Hex, HexData, HexKey, isHexKey } from '../../pages/places/map.model' -import { RootState } from '../../store/store' import { createStateStorage } from '../../store/persist/state-storage' +import { RootState } from '../../store/store' +import { selectAllExplorationNotes } from '../journal/journal-slice' export const hexSchema = z.object({ hexKey: z.string().refine(isHexKey, { message: 'Hex key is not valid', }), + /** + * @deprecated + */ explored: z.boolean(), }) @@ -64,6 +68,9 @@ export const mapStateSchema = z.object({ fogOfWar: z.boolean(), maps: z.object({ ravland: z.object({ + /** + * @deprecated + */ hasExploredHexes: z.boolean(), hexes: z.array(hexSchema), selectedHex: z @@ -72,6 +79,9 @@ export const mapStateSchema = z.object({ .optional(), }), bitterReach: z.object({ + /** + * @deprecated + */ hasExploredHexes: z.boolean(), hexes: z.array(hexSchema), selectedHex: z @@ -97,11 +107,17 @@ export const initialMapState: MapState = { fogOfWar: false, maps: { ravland: { + /** + * @deprecated + */ hasExploredHexes: false, hexes: [], selectedHex: undefined, }, bitterReach: { + /** + * @deprecated + */ hasExploredHexes: false, hexes: [], selectedHex: undefined, @@ -125,31 +141,6 @@ const mapSlice = createSlice({ unsetSelectedHex(state) { state.maps[state.source].selectedHex = undefined }, - updateHex(state, action: PayloadAction) { - const { hexKey, explored } = action.payload - - const map = state.maps[state.source] - const hasHex = map.hexes.some((h) => h.hexKey === hexKey) - - const updatedHexes = hasHex - ? map.hexes.map((h) => { - if (h.hexKey !== hexKey) { - return h - } - - return { - ...h, - explored, - } - }) - : [...state.maps[state.source].hexes, { hexKey, explored }] - - state.maps[state.source] = { - hexes: updatedHexes, - selectedHex: map.selectedHex, - hasExploredHexes: updatedHexes.some((h) => h.explored), - } - }, handlePasteSuccess(_, action: PayloadAction) { return action.payload }, @@ -160,19 +151,25 @@ export const { setSource, toggleFogOfWar, - updateHex, setSelectedHex, unsetSelectedHex, handlePasteSuccess, } = mapSlice.actions -export const selectSource = (state: RootState) => state.map.source -export const selectFogOfWar = (state: RootState) => state.map.fogOfWar -export const selectMap = (state: RootState): GameMapViewModel => { - const map = state.map.maps[state.map.source] +const selectMapState = (state: RootState) => state.map +export const selectSource = createSelector( + selectMapState, + (state) => state.source, +) +export const selectFogOfWar = createSelector( + selectMapState, + (state) => state.fogOfWar, +) + +export const selectMap = createSelector(selectMapState, (mapState) => { + const map = mapState.maps[mapState.source] return { - hasExploredHexes: map.hasExploredHexes, selectedHex: notNullish(map.selectedHex) ? Some(map.selectedHex) : None, hexes: initialHexas.map((hex) => { const userHex = map.hexes.find((h) => h.hexKey === hex.hexKey) @@ -187,8 +184,19 @@ export const selectMap = (state: RootState): GameMapViewModel => { return hex }), } -} +}) export const selectMapSerializable = (state: RootState): MapState => state.map +export const selectHex = (hexKey: HexKey) => + createSelector(selectMap, (map): Option => { + const hex = map.hexes.find((hex) => hex.hexKey === hexKey) + + if (!hex) { + return None + } + + return Some(hex) + }) + export default mapSlice.reducer diff --git a/src/features/monsters/data/random-monster.data.ts b/src/features/monsters/data/random-monster.data.ts index 42234f1..84fd18d 100644 --- a/src/features/monsters/data/random-monster.data.ts +++ b/src/features/monsters/data/random-monster.data.ts @@ -303,7 +303,7 @@ export const monsterTraits: WeightedChoice[] = [ ...rm.attributes, strength: maybe(rm.attributes.strength) .map((s) => s + 2) - .withDefault(2), + .unwrapOr(2), }, }), }, @@ -319,7 +319,7 @@ export const monsterTraits: WeightedChoice[] = [ ...rm.attributes, strength: maybe(rm.attributes.strength) .map((s) => Math.ceil(s / 2)) - .withDefault(1), + .unwrapOr(1), }, }), }, diff --git a/src/features/monsters/random-monster.functions.ts b/src/features/monsters/random-monster.functions.ts index 1334b0b..73ca58e 100755 --- a/src/features/monsters/random-monster.functions.ts +++ b/src/features/monsters/random-monster.functions.ts @@ -221,7 +221,7 @@ export const getMovement = ( const { type, distanceFn } = randomFunc(movementTypes).value return { - distance: maybe(agility).map(distanceFn).withDefault(0), + distance: maybe(agility).map(distanceFn).unwrapOr(0), type, } } @@ -248,7 +248,7 @@ export const getTraitListBasedOnMotivation = ( return [ maybe(hurt) .map((a) => [a.value]) - .withDefault([]), + .unwrapOr([]), traitList, ] } diff --git a/src/functions/utils.functions.ts b/src/functions/utils.functions.ts index 5a02f1d..70c4320 100644 --- a/src/functions/utils.functions.ts +++ b/src/functions/utils.functions.ts @@ -1,5 +1,6 @@ import { nanoid } from 'nanoid' import { range } from 'ramda' +import { None, Option, Some } from 'ts-results' export const identity = (x: T): T => x @@ -42,32 +43,12 @@ export const numberToBooleans = (to: number | Nullish) => { return range(0, to).map((_) => false) } -interface MaybeType { - map: (fn: (val: T) => U) => MaybeType - value: () => T | undefined - withDefault: (defaultValue: U) => T | U -} - -export const maybe = (val?: T): MaybeType => { - const innerValue = val ?? undefined - - return { - map: (fn) => { - if (isNullish(innerValue)) { - return maybe() - } - - return maybe(fn(innerValue)) - }, - value: () => innerValue, - withDefault: (defaultValue) => { - if (isNullish(innerValue)) { - return defaultValue - } - - return innerValue - }, +export const maybe = (val?: T): Option> => { + if (notNullish(val)) { + return Some(val) } + + return None } export const validNumber = ( diff --git a/src/models/forbidden-lands-date.model.ts b/src/models/forbidden-lands-date.model.ts index 14693ec..c07bc92 100644 --- a/src/models/forbidden-lands-date.model.ts +++ b/src/models/forbidden-lands-date.model.ts @@ -3,12 +3,14 @@ import { range } from '../functions/array.functions' import { TranslationKey } from '../store/translations/translation.model' import { padZero2, padZero4 } from '../functions/string-format.functions' import { clamp } from '../functions/math.functions' +import { z } from 'zod' -export const monthIndices = [0, 1, 2, 3, 4, 5, 6, 7] as const -export const isMonthIndex = (val: number): val is MonthIndex => - monthIndices.includes(val as MonthIndex) +export const DEFAULT_START_YEAR = 1165 -export type MonthIndex = (typeof monthIndices)[number] +const MONTH_INDICES = [0, 1, 2, 3, 4, 5, 6, 7] as const +export type MonthIndex = (typeof MONTH_INDICES)[number] +export const isMonthIndex = (val: number): val is MonthIndex => + MONTH_INDICES.includes(val as MonthIndex) export const monthLabelDict: Record< MonthIndex, @@ -184,6 +186,13 @@ export const parseForbiddenLandsDate = ( }) } +export const forbiddenLandsDateStringSchema = z.string().refine( + (v) => parseForbiddenLandsDate(v).ok, + (key) => ({ + message: `Key: "${key}" is not valid ForbiddenLandsDate. Should be YYYY-M-D`, + }), +) + export type ForbiddenLandsDateSerializable = { readonly year: number readonly month: MonthNumber @@ -191,6 +200,30 @@ export type ForbiddenLandsDateSerializable = { readonly day: number } +export const forbiddenLandsDateSerializableSchema = z + .object({ + year: z.number().int().positive().default(DEFAULT_START_YEAR), + month: z.number().int().min(1).max(8).default(1), + monthIndex: z.number().int().min(0).max(7).default(0), + day: z.number().int().min(1).max(46).default(1), + }) + .refine( + (date): date is ForbiddenLandsDateSerializable => { + if (date.monthIndex !== ((date.month - 1) as MonthIndex)) { + return false + } + + if (date.day > daysInMonth[date.monthIndex as MonthIndex]) { + return false + } + + return true + }, + (invalidDate) => ({ + message: `Invalid date: ${JSON.stringify(invalidDate)}`, + }), + ) + export type ForbiddenLandsDate = ForbiddenLandsDateSerializable & { readonly toString: () => string readonly format: () => string @@ -199,6 +232,14 @@ export type ForbiddenLandsDate = ForbiddenLandsDateSerializable & { readonly serialize: () => ForbiddenLandsDateSerializable } +export function formatForbiddenLandsDate({ + day, + month, + year, +}: ForbiddenLandsDateSerializable): string { + return `${padZero4(year)}-${padZero2(month)}-${padZero2(day)}` +} + export class ForbiddenLandsDateClass implements ForbiddenLandsDate { readonly year: number readonly month: MonthNumber @@ -260,14 +301,8 @@ export class ForbiddenLandsDateClass implements ForbiddenLandsDate { return dayIndex } - toString(): string { - return `${this.year}-${this.month}-${this.day}` - } - format(): string { - return `${padZero4(this.year)}-${padZero2(this.month)}-${padZero2( - this.day, - )}` + return formatForbiddenLandsDate(this) } serialize(): ForbiddenLandsDateSerializable { @@ -331,9 +366,6 @@ export class ForbiddenLandsDateClass implements ForbiddenLandsDate { // } // } -export const DEFAULT_START_YEAR = 1165 -const MONTH_INDICES: MonthIndex[] = [0, 1, 2, 3, 4, 5, 6, 7] - export type MoonPhase = 'full' | 'new' | 'normal' export type CalendarDay = { diff --git a/src/pages/places/MapPage.tsx b/src/pages/places/MapPage.tsx index 298cb9c..870775a 100755 --- a/src/pages/places/MapPage.tsx +++ b/src/pages/places/MapPage.tsx @@ -11,6 +11,7 @@ import { Train } from '../../components/Stack' import { PageHeader } from '../../components/page-header' import { Parchment } from '../../components/parchment' import { PasteData } from '../../components/paste-data' +import { selectJournal } from '../../features/journal/journal-slice' import { MapState, handlePasteSuccess, @@ -23,10 +24,9 @@ import { setSelectedHex, setSource, toggleFogOfWar, - unsetSelectedHex, - updateHex, } from '../../features/map/map-slice' import { downloadFile } from '../../functions/file.functions' +import { notNullish } from '../../functions/utils.functions' import { safeJSONParse } from '../../store/persist/json-parsing' import { useAppDispatch, useAppSelector } from '../../store/store.hooks' import { TranslationKey } from '../../store/translations/translation.model' @@ -40,9 +40,17 @@ export const MapPage = () => { const t = useAppSelector(selectTranslateFunction(['map', 'common'])) const source = useAppSelector(selectSource) const fogOfWar = useAppSelector(selectFogOfWar) - const { hasExploredHexes, hexes, selectedHex } = useAppSelector(selectMap) + const { hexes, selectedHex } = useAppSelector(selectMap) const serializableMap = useAppSelector(selectMapSerializable) const dispatch = useAppDispatch() + const { explorationNotes } = useAppSelector(selectJournal) + const hasExploredHexes = useMemo( + () => + Object.values(explorationNotes).some((note) => + notNullish(note.exploredAt), + ), + [explorationNotes], + ) const parchmentRef = useRef(null) @@ -126,7 +134,7 @@ export const MapPage = () => { ) setMapPopover({ - hex, + hexKey: hex.hexKey, x: rect.left, y: rect.top, mapMinX: parchmentRect.x, @@ -155,7 +163,7 @@ export const MapPage = () => { ravland: { hexes: data.hexes, selectedHex: undefined, - hasExploredHexes: hexes.some((hex) => hex.explored), + hasExploredHexes: false, }, bitterReach: { hexes: [], @@ -223,6 +231,7 @@ export const MapPage = () => { )} {t(fogOfWar ? 'map:fog_of_war_on' : 'map:fog_of_war_off')} +
REWRITE IMPORT AND EXPORT
{ > {tooltip.text} - dispatch(updateHex(hex))} - onHide={() => dispatch(unsetSelectedHex())} - > + {mapPopover ? : null} {hexes.map((hex) => ( handleMouseOver(e, hex)} onClick={(e) => handleHexClick(e, hex)} diff --git a/src/pages/places/map-popover.tsx b/src/pages/places/map-popover.tsx index 629626f..fbf7823 100755 --- a/src/pages/places/map-popover.tsx +++ b/src/pages/places/map-popover.tsx @@ -1,12 +1,20 @@ import { FireIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid' -import { useCallback, useEffect, useRef, useState } from 'react' +import { + ComponentPropsWithoutRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' import { ParchmentButton } from '../../components/ParchmentButton' import { Parchment } from '../../components/parchment' -import { useAppSelector } from '../../store/store.hooks' +import { useAppDispatch, useAppSelector } from '../../store/store.hooks' import { selectTranslateFunction } from '../../store/translations/translation.slice' -import { Hex } from './map.model' -export interface MapPopoverOptions { - hex: Hex +import { HexKey } from './map.model' + +export type MapPopoverOptions = { + hexKey: HexKey x: number y: number mapMinX: number @@ -15,134 +23,282 @@ export interface MapPopoverOptions { mapMaxY: number } -export interface MapPopoverProps { - options?: MapPopoverOptions - onExploreChanged: (hex: Hex) => void - onHide: () => void +export type MapPopoverProps = { + options: MapPopoverOptions } -export const MapPopover = ({ - options, - onExploreChanged, - onHide, -}: MapPopoverProps) => { +export const MapPopover = ({ options }: MapPopoverProps) => { const t = useAppSelector(selectTranslateFunction(['map'])) + // const hex = useAppSelector(selctexpl(options.hex.hexKey)) + + const { hex, note } = useAppSelector(selectNote(options.hexKey)) + const dispatch = useAppDispatch() const ref = useRef(null) const [show, setShow] = useState(true) - const initialPosition = -9999 - const [position, setPosition] = useState({ - x: initialPosition, - y: initialPosition, - }) - - const getX = useCallback( - (xOptions?: MapPopoverOptions) => { - if (!xOptions || !ref.current) { - return initialPosition - } - - const { x, mapMaxX, mapMinX } = xOptions - - const rect = ref.current.getBoundingClientRect() - const popoverX = x - rect.width / 2 - - if (popoverX - mapMinX < 0) { - return 0 - } - - if (popoverX > mapMaxX) { - return mapMaxX - rect.width - } - - return popoverX - mapMinX - }, - [initialPosition], - ) - - const getY = useCallback( - (yOptions?: MapPopoverOptions) => { - if (!yOptions || !ref.current) { - return initialPosition - } - - const { y, mapMinY } = yOptions - - const rect = ref.current.getBoundingClientRect() - const popoverY = y - rect.height - mapMinY - 2 - - if (popoverY < 0) { - return mapMinY - } - - return popoverY - }, - [initialPosition], - ) useEffect(() => { if (options) { - setPosition({ x: getX(options), y: getY(options) }) setShow(true) } - }, [getX, getY, options, ref]) + }, [options, ref]) + + if (hex.none) { + return null + } return (
- {options && ( - -
- {options.hex.hexKey}:{' '} - {options.hex.explored - ? t('map:popover_explored') - : t('map:popover_unexplored')} -
-
+ {options ? ( + +
{ - onHide() + dispatch(unsetSelectedHex()) setShow(false) }} > {t('map:popover_hide')} +
+
+ ) : null} +
+ ) +} - {options.hex.explored ? ( - { - setShow(false) - onHide() - onExploreChanged({ ...options.hex, explored: false }) - }} - > +type ExplorationNoteProps = ComponentPropsWithoutRef<'div'> & { + explorationNote: ExplorationNoteViewModel +} +function ExplorationNote({ + explorationNote: { hexKey, note, exploredAt }, + children, +}: ExplorationNoteProps) { + const t = useAppSelector(selectTranslateFunction(['map'])) + const dispatch = useAppDispatch() + const { currentDate } = useAppSelector(selectCurrentDate) + + return ( + +
+ + HEX + {hexKey} + + + EXPLORED + + {exploredAt.some ? exploredAt.val.format() : ''} + + + { + dispatch( + toggleExploredAt({ + hexKey, + exploredAt: currentDate.serialize(), + }), + ) + }} + > + {exploredAt.some ? ( - {t('map:popover_forget')} - - ) : ( - { - setShow(false) - onHide() - onExploreChanged({ ...options.hex, explored: true }) - }} - > + ) : ( - {t('map:popover_explore')} - - )} + )} + {t( + exploredAt.some ? 'map:popover_forget' : 'map:popover_explore', + )} + + + + +
+ {/* NOTE */} + +
- - )} +
+
+ {children} +
+ ) +} + +type PrintedTextProps = ComponentPropsWithoutRef<'div'> +const PrintedText = ({ children, className }: PrintedTextProps) => ( +
+ {children} +
+) + +type NoteBoxProps = ComponentPropsWithoutRef<'div'> +const NoteBox = ({ children, className }: NoteBoxProps) => ( +
+ {children} +
+) + +import type { AriaTextFieldProps } from 'react-aria' +import { useTextField } from 'react-aria' +import { selectCurrentDate } from '../../features/calendar/calendar-slice' +import { + ExplorationNoteViewModel, + NOTE_MAX_LENGTH, + selectNote, + toggleExploredAt, + upsertExplorationNote, +} from '../../features/journal/journal-slice' +import { unsetSelectedHex } from '../../features/map/map-slice' +import { isString } from '../../functions/utils.functions' + +type TextAreaProps = AriaTextFieldProps & ComponentPropsWithoutRef<'textarea'> +function TextArea(props: TextAreaProps) { + const { label } = props + const [inputValue, setInputValue] = useState( + props.value ?? props.defaultValue, + ) + + const [isFocused, setIsFocused] = useState(false) + + const ref = useRef(null) + const { className, onChange, onFocus, onBlur } = props + + const heightHandler = useCallback(() => { + if (ref.current) { + const area = ref.current + const oldAlignment = area.style.alignSelf + const oldOverflow = area.style.overflow + const isFirefox = 'MozAppearance' in area.style + if (isFirefox) { + area.style.overflow = 'hidden' + } + area.style.alignSelf = 'start' + area.style.height = 'auto' + const height = `${ + area.scrollHeight + (area.offsetHeight - area.clientHeight) + }px` + area.style.height = height + console.log('[TextArea] height', height) + console.log('[TextArea] area.scrollHeight', area.scrollHeight) + console.log('[TextArea] area.offsetHeight', area.offsetHeight) + console.log('[TextArea] area.clientHeight', area.clientHeight) + + area.style.overflow = oldOverflow + area.style.alignSelf = oldAlignment + } + }, [ref]) + + useLayoutEffect(() => { + if (ref.current) { + heightHandler() + } + }, [heightHandler, inputValue, ref, props.value]) + + const { labelProps, inputProps } = useTextField( + { + ...props, + onFocus: (e) => { + setIsFocused(true) + if (onFocus) { + onFocus(e) + } + }, + onBlur: (e) => { + setIsFocused(false) + if (onBlur) { + onBlur(e) + } + }, + onChange: (e) => { + setInputValue(e) + if (onChange) { + onChange(e) + } + }, + + inputElementType: 'textarea', + }, + ref, + ) + + return ( +
+
+ +
NOTE_MAX_LENGTH + ? 'text-red-500' + : '' + } + ${ + isFocused && (inputValue?.length ?? 0) > 100 + ? 'opacity-100' + : 'opacity-0' + } + `} + > + {inputValue?.length ?? 0} / {NOTE_MAX_LENGTH} characters +
+
+