diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4db24f1a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Umami analytics (production only — leave unset locally to avoid polluting the dashboard) +NEXT_PUBLIC_UMAMI_SRC= +NEXT_PUBLIC_UMAMI_WEBSITE_ID= + +# Sentry error monitoring (production only — leave unset locally) +NEXT_PUBLIC_SENTRY_DSN= +SENTRY_AUTH_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6767294f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run test + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: npm run test:e2e + - if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 88b6f0d9..dd09e546 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/test-results/ +/playwright-report/ # next.js /.next/ @@ -35,3 +37,9 @@ yarn-error.log* # typescript *.tsbuildinfo + +# Per-day chunks generated by scripts/build-day-data.mjs from data/data.json +/data/days/ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..70709fd0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,169 @@ +# lostark-alarms — agent guide + +Public Next.js app at `lostarktimer.app`. Two routes: `/alarms` (event +schedule + countdown timers) and `/merchants` (currently parked behind +a WIP banner pending crowdsourced rebuild). + +## Stack + +- **Next.js 16.2.5** (Pages Router, Turbopack) / **React 19** / **TS 5** +- **Tailwind 4** (CSS-first config in `styles/globals.css`) — no Tailwind config file +- **shadcn/ui** (Radix primitives + cva) — components in `components/ui/` +- **Luxon 3** for time/timezone math (uses IANA names, not UTC offsets) +- **react-i18next** for i18n (EN/ZH); EN is auto-derived from `data/events.json` metadata, ZH overrides via `public/locales/zh/` +- **Howler** for alarm sound playback +- **Vitest** (unit, jsdom) + **Playwright** (e2e, chromium) +- **Node 22** pinned via `.nvmrc`. Older Node will fail Next 16's check. + +## Layout + +``` +pages/ + _app.tsx page shell, footer, modal-state owners (gh + changelog) + _document.tsx inline pre-hydration script that sets .dark class to avoid FOUC + _error.tsx Pages-Router error boundary; forwards exceptions to Sentry + alarms.tsx main route — date selector, filter sidebar, event cards + merchants.tsx merchant tracker (parked, WIP banner) +components/ + ui/ shadcn primitives (Button, Dialog, Select, etc.) + modals/ Alarm/Merchant/Changelog/GitHub modals — controlled + analytics/ UmamiScript + CloudflareAnalytics (env-driven, prod-only) + GameEventTableCell.tsx per-event card with disable DropdownMenu + MerchantTableCell.tsx per-merchant card + NavBar.tsx header (brand, tabs, locale) + SideBar.tsx fixed icon column on lg+ (gh, paypal, changelog) +instrumentation.ts Next 16 server/edge bootstrap → Sentry init +instrumentation-client.ts Next 16 client bootstrap → Sentry init +sentry.{server,edge}.config.ts per-runtime Sentry init (gated on VERCEL_ENV) +.env.example documents Umami + Sentry public/private vars (no Firebase/Mongo — both unused) +util/ + alarmTrigger.ts isWithinNotifyWindow predicate (pure, tested) + createTableData.tsx row-pairing + time-chip rendering + i18n.ts i18next bootstrap with bundled EN+ZH + static.ts RegionTimeZoneMapping + resolveRegion migration shim + useLocalStorage.ts in-house hook with cross-instance broadcast + usePrevious.tsx + types/types.ts RegionKey/ServerKey derived from data/regions.json +common/ + GameEvent.ts event model (latest(t) gives next interval) + WanderingMerchant.ts merchant model + api/APIEventType.ts, APIGameEvent.ts +data/ + data.json event schedule (committed, ~76 KB; regenerated by scraper) + events.json event id → [name, icon, ilevel] metadata + msgs.json event-type label/icon + days/ GENERATED, per-day chunks of data.json (gitignored) + regions.json region → server[] (current 2024 topology) + itemMapping.json, itemRarity.json, merchants.json, merchantSchedules.json + └─ merchant-only static data; loaded by /merchants +scripts/ + build-day-data.mjs prebuild pass that splits data.json → data/days/ +public/ + sounds/alert_*.mp3 alarm sounds (loaded via URL strings, not webpack assets) + locales/{en,zh}/*.json i18next overrides (EN file is largely stale, kept for ZH) +test/ + unit/ Vitest specs + e2e/ Playwright specs +``` + +## Critical conventions + +### Don't expose `data/*.json` at a stable URL +The cleaned schedule is the project's value-add. Putting any of +`data/data.json`, `data/events.json`, `data/msgs.json` (or their +per-day chunks) under `public/` would let anyone curl them. Keep them +imported via `require()` / dynamic `import()` so they end up in +fingerprinted webpack chunks instead. + +### Use IANA timezones, not UTC offsets +`util/static.ts` maps region → IANA tz (`America/New_York`, etc.). +Luxon then handles DST automatically. The legacy `UTC-7` strings +silently produced wrong times for half the year. + +### Per-day chunks are derived, not committed +`scripts/build-day-data.mjs` runs on `predev` and `prebuild` and +splits `data/data.json` into `data/days/-.json` so the +alarms page only fetches the visible day. The output dir is +gitignored — recreated on every build. If you change `data.json`, +the next build picks it up. + +### `+10 min` adjustment must apply to BOTH start and end +`pages/alarms.tsx` shifts certain type-IDs (sailing, ocean co-ops) and +event-IDs forward by 10 min to match in-game spawn timing. After +`normalize.py` started canonicalising single times into degenerate +`HH:MM-HH:MM` ranges, applying the offset only to start (the legacy +behavior in `pages/alarms.tsx`) produced 23h50m "spanning-midnight" +windows. The fix is to apply +10 to both. If you ever revisit the +adjustment, both call sites need the same treatment. + +### `useLocalStorage` broadcasts on set +Multiple consumers of the same key (page + modal) used to drift — +toggling a checkbox in the settings modal didn't update the page +until reload. The hook now dispatches a `lostark:localStorage` +custom event on every `setValue`, and every other instance subscribed +to that key updates in place. Don't replace it with raw +`localStorage` access without restoring the broadcast. + +### Theme uses `class="dark"`, not `data-theme` +`_document.tsx` has an inline pre-hydration script that reads +`localStorage.darkMode` and applies the `.dark` class on `` so +there's no white flash on a dark-mode reload. The class hook in +`globals.css` is `@custom-variant dark (&:is(.dark *))`. If you ever +re-introduce `data-theme`, the FOUC script needs updating too. + +### Modal pattern is controlled +All four modals (`AlarmConfigModal`, `MerchantConfigModal`, +`ChangeLogModal`, `GitHubModal`) take `open` + `onOpenChange` props. +State lives in the page (`pages/_app.tsx` for gh+changelog, +`pages/alarms.tsx` and `pages/merchants.tsx` for their config modals). +The DaisyUI checkbox-toggle pattern is gone. + +### Each `` needs a `` +Radix Dialog warns at runtime without one. Use `sr-only` if a visible +description doesn't fit the design. + +## Commands + +```bash +npm run dev # local dev server (runs prebuild data:days first) +npm run build # production build (runs prebuild data:days first) +npm run start # serve the build +npm run typecheck # tsc --noEmit +npm run test # Vitest (unit, jsdom) +npm run test:watch +npm run test:coverage +npm run test:e2e # Playwright (chromium) +npm run test:e2e:install # one-time chromium download +npm run data:days # regenerate data/days/ from data/data.json +``` + +CI: `.github/workflows/ci.yml` — typecheck + Vitest on PR; separate +e2e job builds and runs Playwright with artifact upload. + +## Data pipeline + +`data/{data,events,msgs}.json` is updated by an external pipeline that +runs weekly and opens PRs here with cleaned schedule files. PRs are +titled `chore(data): weekly schedule update`. The pipeline lives outside +this repo. See `docs/decisions/0015-scraper-resiliency.md`. + +## What the merchants page is doing + +Parked. The `wss://ws.lostarktimer.app` socket was disconnected in +this rewrite — there's no live data. The page renders a "Work in +Progress" banner explaining the upcoming crowdsourced-vote model. +`socket.io-client` is uninstalled. Static merchant data +(`data/merchants.json`, `data/merchantSchedules.json`) still feeds +the schedule reference cards. + +## Related repos + +- `~/Projects/WanderLost` — clone of `Xeio/WanderLost` (powers + `lostmerchants.com`). Reference for the 2024 server-merge topology + in `data/regions.json` and the eventual merchants rebuild. + +## See also + +- `docs/decisions/` — ADRs for non-obvious choices (start at the README) +- `docs/ARCHITECTURE.md` — runtime data flow diagram +- `docs/DEPLOY.md` — deploy steps diff --git a/common/GameEvent.ts b/common/GameEvent.ts index 83e6bd26..215a0ca6 100644 --- a/common/GameEvent.ts +++ b/common/GameEvent.ts @@ -17,7 +17,9 @@ class GameEvent { this.times.push(t) } latest(t: DateTime): Interval { - return this.times.filter((ti) => ti.start.diff(t).valueOf() > 0)[0] + return this.times.filter( + (ti) => ti.start !== null && ti.start.diff(t).valueOf() > 0 + )[0] } } diff --git a/common/Item.ts b/common/Item.ts deleted file mode 100644 index c715b3b0..00000000 --- a/common/Item.ts +++ /dev/null @@ -1,9 +0,0 @@ -class Item { - name: String - - constructor(name: String) { - this.name = name - } -} - -export default Item diff --git a/common/Region.ts b/common/Region.ts deleted file mode 100644 index 01e8d175..00000000 --- a/common/Region.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Server from './Server' - -class Region { - name: String - servers: Server[] - - constructor(name: String, servers: Server[]) { - this.name = name - this.servers = servers - } -} - -export default Region diff --git a/common/Server.ts b/common/Server.ts deleted file mode 100644 index a0867f15..00000000 --- a/common/Server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import WanderingMerchant from './WanderingMerchant' - -class Server { - name: String - merchants: WanderingMerchant[] - constructor(name: String, merchants: WanderingMerchant[]) { - this.name = name - this.merchants = merchants - } -} - -export default Server diff --git a/common/WanderingMerchant.ts b/common/WanderingMerchant.ts index b83e263d..ff433bc9 100644 --- a/common/WanderingMerchant.ts +++ b/common/WanderingMerchant.ts @@ -1,6 +1,5 @@ import { DateTime, Interval } from 'luxon' import { v4 as uuidv4 } from 'uuid' -import Item from './Item' type MerchantItems = { rapport: number[]; cards: number[]; cooking: number[] } class WanderingMerchant { @@ -51,9 +50,11 @@ class WanderingMerchant { inProgress(serverTime: DateTime) { if (this.spawnTime != null) return true for (const interval of this.schedule) { + const { start, end } = interval + if (!start || !end) continue if ( - interval.start.diff(serverTime).toMillis() < 0 && - interval.end.diff(serverTime).toMillis() > 0 + start.diff(serverTime).toMillis() < 0 && + end.diff(serverTime).toMillis() > 0 ) return interval } @@ -62,8 +63,10 @@ class WanderingMerchant { nextSpawnTime(serverTime: DateTime) { for (let idx = 0; idx < this.schedule.length; idx++) { let interval = this.schedule[idx] - let startDiff = interval.start.diff(serverTime).toMillis() - let endDiff = interval.end.diff(serverTime).toMillis() + const { start, end } = interval + if (!start || !end) continue + let startDiff = start.diff(serverTime).toMillis() + let endDiff = end.diff(serverTime).toMillis() // case: time before first event of day if (startDiff > 0) return interval diff --git a/components.json b/components.json new file mode 100644 index 00000000..a084701a --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/GameEventTableCell.tsx b/components/GameEventTableCell.tsx index 1816a581..a1db78c6 100644 --- a/components/GameEventTableCell.tsx +++ b/components/GameEventTableCell.tsx @@ -1,12 +1,22 @@ import React, { useEffect, useState } from 'react' import { GameEvent } from '../common' - -import Image from 'next/image' import { DateTime, Duration, Zone } from 'luxon' -import classNames from 'classnames' -import useLocalStorage from '@olerichter00/use-localstorage' +import { MoreVertical } from 'lucide-react' +import useLocalStorage from '../util/useLocalStorage' import { generateTimestampStrings } from '../util/createTableData' -import { useTranslation } from 'next-i18next' +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { track } from './analytics/track' + type CellProps = { gameEvent: GameEvent serverTime: DateTime @@ -14,8 +24,12 @@ type CellProps = { view24HrTime: boolean | undefined } +// Event IDs that the legacy code treated as "repeated grand-prix-style" +// events; toggling 'group repeated events' shows/hides duplicates. +const REPEATED_EVENT_IDS = new Set([945, 946, 947, 948, 949, 950, 951]) + const GameEventTableCell = (props: CellProps): React.ReactElement => { - const { t } = useTranslation('events') + const { t, i18n } = useTranslation('events') const { gameEvent, serverTime, localizedTZ, view24HrTime } = props const [disabledAlarms, setDisabledAlarms] = useLocalStorage<{ [key: string]: number @@ -24,383 +38,241 @@ const GameEventTableCell = (props: CellProps): React.ReactElement => { 'hideGrandPrix', false ) - const [timeUntil, setTimeUntil] = useState( - Duration.fromMillis( - gameEvent.latest(serverTime)?.start.diff(serverTime).toMillis() - ) + + const millisUntilLatest = (against: DateTime): number | null => { + const next = gameEvent.latest(serverTime) + if (!next || !next.start) return null + return next.start.diff(against).toMillis() + } + const initialMs = millisUntilLatest(serverTime) + const [timeUntil, setTimeUntil] = useState( + initialMs === null ? null : Duration.fromMillis(initialMs) ) useEffect(() => { - if (!gameEvent.disabled) { - const timer = setInterval(() => { - // setServerTime() - setTimeUntil( - Duration.fromMillis( - gameEvent - .latest(serverTime) - ?.start.diff(DateTime.now().setZone(serverTime.zone)) - .toMillis() - ) - ) - }, 1000) - - return () => { - clearInterval(timer) // Return a funtion to clear the timer so that it will stop being called on unmount - } - } + if (gameEvent.disabled) return + const timer = setInterval(() => { + const ms = millisUntilLatest(DateTime.now().setZone(serverTime.zone)) + setTimeUntil(ms === null ? null : Duration.fromMillis(ms)) + }, 1000) + return () => clearInterval(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - if (gameEvent === null) { - return ( - - ) + const applyDisable = (until: DateTime | null | undefined) => { + if (!until) return + setDisabledAlarms({ + ...(disabledAlarms ?? {}), + [gameEvent.gameEvent.id]: until.toMillis(), + }) + gameEvent.disabled = until } + + const eventId = String(gameEvent.gameEvent.id) + + const disableWithDuration = ( + until: DateTime | null | undefined, + duration: + | 'once' + | '12hr' + | 'daily-reset' + | 'weekly-reset' + | 'three-weeks' + ) => { + if (!until) return + track('event_disable', { eventId, duration }) + applyDisable(until) + } + + const enable = () => { + if (!disabledAlarms) return + track('event_enable', { eventId }) + const next = { ...disabledAlarms } + delete next[gameEvent.gameEvent.id] + gameEvent.disabled = null + setDisabledAlarms(next) + } + + // When the event has a known upcoming occurrence today, anchor the + // disable window to that interval's end. Otherwise (event has already + // happened for the day) anchor to "now" so the action still does + // something — disable just shifts the user-visible window forward. + const disableAnchor = (): DateTime => { + const next = gameEvent.latest(serverTime) + return next?.end ?? DateTime.now() + } + + const disableOnce = () => disableWithDuration(disableAnchor(), 'once') + const disable12Hours = () => + disableWithDuration(disableAnchor().plus({ hours: 12 }), '12hr') + const disableThreeWeeks = () => + disableWithDuration(disableAnchor().plus({ hours: 336 }), 'three-weeks') + + const disableDailyReset = () => { + let until = DateTime.now().set({ + hour: 5, + minute: 0, + second: 0, + millisecond: 0, + }) + if (until <= DateTime.now()) until = until.plus({ days: 1 }) + disableWithDuration(until, 'daily-reset') + } + + const disableWeeklyReset = () => { + let until = DateTime.now().set({ + weekday: 4, + hour: 5, + minute: 0, + second: 0, + millisecond: 0, + }) + if (until <= DateTime.now()) until = until.plus({ days: 7 }) + disableWithDuration(until, 'weekly-reset') + } + + const isRepeatedEvent = REPEATED_EVENT_IDS.has(Number(gameEvent.gameEvent.id)) + const isGrouped = isRepeatedEvent && hideGrandPrix + const isDisabled = !!gameEvent.disabled + + // Prefer the metadata name from data/events.json — it's freshly + // scraped and accurate. Fall back to i18n only when the metadata is + // empty, or when a non-English locale provides a real translation + // (the en/events.json file is stale; zh/events.json has actual + // translations worth keeping). + const metadataName = gameEvent.gameEvent.name + const translated = t(`${gameEvent.gameEvent.id}`) + const hasTranslation = translated !== gameEvent.gameEvent.id + const eventLabel = isGrouped + ? gameEvent.groupName + : metadataName + ? i18n.language !== 'en' && hasTranslation + ? translated + : metadataName + : hasTranslation + ? translated + : gameEvent.gameEvent.id + return ( - +
-
- -
-
-
- - {!(hideGrandPrix && gameEvent.groupName) && - `[${gameEvent.gameEvent.minItemLevel}] `} - {(hideGrandPrix && gameEvent.groupName) || - t(`${gameEvent.gameEvent.id}`)} - - {gameEvent.disabled - ? null - : `-${timeUntil.toFormat('hh:mm:ss')}`} - - - - {gameEvent.times.map((t, idx) => - generateTimestampStrings( - gameEvent, - t, - serverTime, - localizedTZ, - view24HrTime || false, - idx - ) + {gameEvent.gameEvent.name} + +
+
+

+ {!isGrouped && ( + + [{gameEvent.gameEvent.minItemLevel}] + )} - + {eventLabel} + {isDisabled && ( + + Disabled + {gameEvent.disabled && ( + + · {gameEvent.disabled.toRelative()} + + )} + + )} +

+ {!isDisabled && timeUntil && ( + + {timeUntil.toFormat('hh:mm:ss')} + + )}
+

+ {gameEvent.times.map((interval, idx) => + generateTimestampStrings( + gameEvent, + interval, + serverTime, + localizedTZ, + view24HrTime || false, + idx + ) + )} +

-
- + + ) : ( + <> + + {t('alarms:disable.once')} + + + {t('alarms:disable.12hrs')} + + + {t('alarms:disable.daily-reset')} + + + {t('alarms:disable.weekly-reset')} + + + + {t('alarms:disable.all')} + + + )} + + +
) } -/**return ( - <> - - - - - className={`${ - t.start.diff(serverTime).valueOf() > 0 - ? 'text-success' - : 'text-slate-500' - }`} - >{`${t.start.toLocaleString(DateTime.TIME_24_SIMPLE)}${ - !t.isEmpty() - ? ` - ${t.end.toLocaleString(DateTime.TIME_24_SIMPLE)}` - : '' - } `}{' '} - {idx < gameEvent.times.length - 1 ? ' / ' : ''} - */ export default GameEventTableCell diff --git a/components/MerchantTableCell.tsx b/components/MerchantTableCell.tsx index 79003dad..07231b99 100644 --- a/components/MerchantTableCell.tsx +++ b/components/MerchantTableCell.tsx @@ -1,14 +1,12 @@ import React, { useState, useEffect } from 'react' -import merchantSchedules from '../data/merchantSchedules.json' import itemMapping from '../data/itemMapping.json' import itemRarity from '../data/itemRarity.json' -import { DateTime, Interval, Zone } from 'luxon' -import Image from 'next/image' -import classNames from 'classnames' -import { generateTimestampStrings } from '../util/createTableData' +import { DateTime, Zone } from 'luxon' import WanderingMerchant from '../common/WanderingMerchant' -import useLocalStorage from '@olerichter00/use-localstorage' -import { useTranslation } from 'next-i18next' +import useLocalStorage from '../util/useLocalStorage' +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' type ItemMappingKey = keyof typeof itemMapping @@ -18,48 +16,86 @@ interface CellProps { localizedTZ: Zone view24HrTime: boolean } -let mSchedules: { [key: number]: Interval[] } = {} + +const RARITY_TEXT: Record<0 | 1 | 2 | 3, string> = { + 0: 'text-[#6fc300]', + 1: 'text-[#00b5ff]', + 2: 'text-[#bf00fe]', + 3: 'text-[#f39303]', +} + +const rarityFromId = (id: number): 0 | 1 | 2 | 3 => { + const ir = itemRarity as { [key: string]: 0 | 1 | 2 | 3 } + return ir[String(id)] ?? 0 +} + +const goodItemRarityClass = (goodItem: string | null): string => { + if (!goodItem) return '' + switch (goodItem.split(' ')[0]) { + case 'No': + return RARITY_TEXT[0] + case 'Seria': + case 'Sian': + return RARITY_TEXT[1] + case 'Madnick': + case 'Mokamoka': + case 'Kaysarr': + return RARITY_TEXT[2] + case 'Wei': + case 'Legendary': + return RARITY_TEXT[3] + default: + return '' + } +} + +const iconURL = (item: number) => { + const mapping = itemMapping[String(item) as ItemMappingKey] + return mapping + ? `https://lostarkcodex.com/icons/${mapping.fileName}` + : '' +} + +const openLocationImage = (imageUrl: string, title: string) => { + if (!imageUrl) return + window.open( + `https://i.imgur.com/${imageUrl}`, + title, + 'left=20,top=20,width=1000,height=600,toolbar=0,resizable=1,noopener=1,noreferrer=1' + ) +} const MerchantTableCell = (props: CellProps): React.ReactElement => { const { t } = useTranslation('merchants') const { merchant, serverTime, localizedTZ, view24HrTime } = props - Object.entries(merchantSchedules).forEach( - ([key, schedule]) => - (mSchedules[Number(key)] = schedule.map(({ h, m }) => { - let start = DateTime.fromObject( - { hour: h, minute: m } - // { zone: serverZone } - ) - return Interval.fromDateTimes(start, start.plus({ minutes: 25 })) - })) - ) - const [nextSpawnCountdown, setNextSpawnCountdown] = useState( - merchant - .nextSpawnTime(serverTime) - .start.setZone(serverTime.zone) - .diff(DateTime.now()) - ) - useEffect(() => { - const timer = setInterval(() => { - setNextSpawnCountdown( - merchant - .nextSpawnTime(serverTime) - .start.setZone(serverTime.zone) - .diff(DateTime.now()) - ) - }, 1000) - return () => { - clearInterval(timer) - } - }) - const [hideMerchantItems, setHideMerchantItems] = useLocalStorage( + + const [hideMerchantItems] = useLocalStorage( 'hideMerchantItems', false ) - const [hidePotentialSpawns, sethidePotentialSpawns] = useLocalStorage( + const [hidePotentialSpawns] = useLocalStorage( 'hidePotentialMerchantLocationSpawns', false ) + + const computeCountdown = () => { + const next = merchant.nextSpawnTime(serverTime) + if (!next?.start) return null + return next.start.setZone(serverTime.zone).diff(DateTime.now()) + } + + const [nextSpawnCountdown, setNextSpawnCountdown] = useState( + computeCountdown() + ) + useEffect(() => { + const timer = setInterval(() => setNextSpawnCountdown(computeCountdown()), 1000) + return () => clearInterval(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const inProgress = !!merchant.inProgress(serverTime) + const localInProgress = !!merchant.inProgress(serverTime.setZone(localizedTZ)) + let imageUrl = '' if (merchant.location) { imageUrl = @@ -69,97 +105,49 @@ const MerchantTableCell = (props: CellProps): React.ReactElement => { ) || '' ] } - const gradientColor = (id: 0 | 1 | 2 | 3) => { - if (id === 0) return 'bg-gradient-to-br from-[#1e2f08] to-[#4c8204]' - if (id === 1) return 'bg-gradient-to-br from-[#082c3b] to-[#0479a9]' - if (id === 2) return 'bg-gradient-to-br from-[#2e083b] to-[#8004a9]' - if (id === 3) return 'bg-gradient-to-br from-[#392509] to-[#a16305]' - } - const textColor = (id: 0 | 1 | 2 | 3) => { - if (id === 0) return 'text-[#6fc300]' - if (id === 1) return 'text-[#00b5ff]' - if (id === 2) return 'text-[#bf00fe]' - if (id === 3) return 'text-[#f39303]' - } - const rarity = (id: number): 0 | 1 | 2 | 3 => { - let ir = itemRarity as { [key: string]: 0 | 1 | 2 | 3 } - return ir[String(id)] - } - const merchantGoodItemToRarity = (goodItem: string | null): string => { - if (goodItem) { - let first = goodItem.split(' ')[0] - switch (first) { - case 'No': - return 'text-[#6fc300]' - case 'Seria': - case 'Sian': - return 'text-[#00b5ff]' - case 'Madnick': - case 'Mokamoka': - case 'Kaysarr': - return 'text-[#bf00fe]' - case 'Wei': - case 'Legendary': - return 'text-[#f39303]' - default: - return '' - } - } - return '' - } - const onClickOpenWindow = (imageUrl: string, title: string) => { - window.open( - `https://i.imgur.com/${imageUrl}`, - title, - 'left=20,top=20,width=1000,height=600,toolbar=0,resizable=1,noopener=1,noreferrer=1' - ) - return false - } - const iconURL = (item: number) => { - let iconName = itemMapping[String(item) as ItemMappingKey].fileName - return `https://lostarkcodex.com/icons/${iconName}` - } - //class="dropdown m-2 flex basis-1/2 bg-stone-100 p-2 hover:cursor-pointer dark:bg-base-100 dark:hover:bg-base-100/70" - return ( - - {/*
*/} -
-
- - +
+
+
+

- {merchant.name} ({t(`locations.${merchant.continent}`)}){' '} - {merchant.inProgress(serverTime) ? ( - - {' '} + {merchant.name}{' '} + + ({t(`locations.${merchant.continent}`)}) + + {inProgress && ( + {serverTime - .set({ - minute: 30, - }) + .set({ minute: 30 }) .setZone(localizedTZ) .toLocaleString( view24HrTime ? DateTime.TIME_24_SIMPLE : DateTime.TIME_SIMPLE )}{' '} - -{' '} + –{' '} {serverTime .set({ minute: 55 }) .setZone(localizedTZ) @@ -169,145 +157,125 @@ const MerchantTableCell = (props: CellProps): React.ReactElement => { : DateTime.TIME_SIMPLE )} - ) : null} - + )} +

- {merchant.inProgress(serverTime) ? ( -
- - {t('location')}:{' '} - - {merchant.spawned ? ( - - onClickOpenWindow(imageUrl, merchant.location || '') - } - > - {t(`locations.${merchant.location}`)} - - ) : ( - 'Unknown' - )} - - -
- Item:{' '} - {merchant.goodItems - ? merchant.goodItems.map((goodItem, index) => ( - - {goodItem} - {index === 0 ? , : ''} - - )) - : 'Unknown'} + {inProgress && ( +
+
+ {t('location')}: + {merchant.spawned ? ( + + ) : ( + Unknown + )} +
+
+ Item: + {merchant.goodItems + ? merchant.goodItems.map((goodItem, index) => ( + + {goodItem} + {index === 0 ? ', ' : ''} + + )) + : 'Unknown'} +
- ) : null} - - - {t('next-spawn')}:{' '} - {merchant - .nextSpawnTime(serverTime) - ?.start.setZone(localizedTZ) - .toLocaleString( - view24HrTime ? DateTime.TIME_24_SIMPLE : DateTime.TIME_SIMPLE - )} - - -{nextSpawnCountdown.toFormat('hh:mm:ss')} - - - {merchant.spawned}{' '} -
-
- {t('potential-spawns')} - {Object.entries(merchant.locationImages).map( - ([locationName, imgUrl], idx, arr) => ( - - onClickOpenWindow(imgUrl, locationName)} - > - {t(`locations.${locationName}`)} + )} + +
+ {t('next-spawn')}: + {nextSpawnLabel} + {!inProgress && nextSpawnCountdown && ( + + {nextSpawnCountdown.toFormat('hh:mm:ss')} - {idx + 1 < arr.length ?
: ''} - - ) + )} +
+
+ + {!merchant.spawned && !hidePotentialSpawns && ( +
+
{t('potential-spawns')}
+
+ {Object.entries(merchant.locationImages).map( + ([locationName, imgUrl]) => ( + + ) + )} +
+
)} -
-
-
+ {[ + { title: 'Rapport', items: merchant.items.rapport }, + { title: 'Cards', items: merchant.items.cards }, + { title: 'Cooking', items: merchant.items.cooking }, + ] + .filter(({ items }) => items.length > 0) + .map(({ title, items }) => ( +
+

+ {title} +

+
    + {items.map((item) => ( +
  • + { + + {t(`items.${item}.name`)} + +
  • + ))} +
+
+ ))} +
)} - > - {[ - { title: 'Rapport', items: merchant.items.rapport }, - { title: 'Cards', items: merchant.items.cards }, - { title: 'Cooking', items: merchant.items.cooking }, - ].map(({ title, items }, idx) => { - return items.length ? ( -
-
- {title} - {items.map((item) => ( -
- - - {t(`items.${item}.name`)} - -
- ))} -
-
- ) : null - })}
- {/*
*/} ) } diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 0f04754f..6f7f5d17 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -1,108 +1,112 @@ -import classNames from 'classnames' import Link from 'next/link' import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' -import { IconLanguage } from '@tabler/icons' -const NavBar = () => { - const { t } = useTranslation('common') +import { useTranslation } from 'react-i18next' +import { Languages } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +const NavBar = () => { + const { t, i18n } = useTranslation('common') const router = useRouter() + + const isAlarms = router.pathname === '/alarms' || router.pathname === '/' + const isMerchants = router.pathname === '/merchants' + return ( <> -
- + Discord Bot now available — click here to join! +
-
-
-
- - - {t('alarm-link-text')} - - - - - {t('merchant-link-text')} +
+
+
+ + Lost Ark Timer + +
-
-
- Lost Ark Timer -
+
Discord - GitHub - Support +
+ + +
-
- - -
-
+
) } diff --git a/components/SideBar.tsx b/components/SideBar.tsx index 16aa0f5c..176872a7 100644 --- a/components/SideBar.tsx +++ b/components/SideBar.tsx @@ -2,35 +2,62 @@ import React from 'react' import { IconBrandGithub, IconBrandPaypal, - IconCoffee, IconFileCode, -} from '@tabler/icons' +} from '@tabler/icons-react' -const SideBar = () => { +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { track } from './analytics/track' + +interface Props { + onOpenGithub: () => void + onOpenChangelog: () => void +} + +const SideBar = ({ onOpenGithub, onOpenChangelog }: Props) => { return ( - ) diff --git a/components/analytics/CloudflareAnalytics.tsx b/components/analytics/CloudflareAnalytics.tsx new file mode 100644 index 00000000..871f6c7f --- /dev/null +++ b/components/analytics/CloudflareAnalytics.tsx @@ -0,0 +1,14 @@ +import Script from 'next/script' + +export function CloudflareAnalytics() { + if (process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production') return null + + return ( + {' '} - - ) : null} + + + ) } -export default appWithTranslation(MyApp) +export default MyApp diff --git a/pages/_document.tsx b/pages/_document.tsx index 73c17d62..6f8d0858 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,28 +1,30 @@ -import Document, { - DocumentContext, - Html, - Head, - Main, - NextScript, -} from 'next/document' +import { Html, Head, Main, NextScript } from 'next/document' -class MyDocument extends Document { - static async getInitialProps(ctx: DocumentContext) { - const initialProps = await Document.getInitialProps(ctx) +// Pre-hydration script that applies the dark class before React paints, +// preventing the light-mode flash on a dark-mode reload. +const themeInitScript = `(function() { + try { + var stored = localStorage.getItem('darkMode'); + var useDark; + if (stored === null) { + useDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } else { + useDark = JSON.parse(stored) === true || stored === '"true"' || stored === 'true'; + } + if (useDark) document.documentElement.classList.add('dark'); + } catch (e) {} +})();` - return initialProps - } - render() { - return ( - - - -
- - - - ) - } +export default function MyDocument() { + return ( + + +