diff --git a/bun.lockb b/bun.lockb index 247b705..ce14b23 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d8ab0e3..73e1e97 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "@vitejs/plugin-react": "^4.3.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^24.2.3", "motion": "^11.11.17", "openapi-fetch": "^0.13.0", "react-dom": "^18.3.1", + "react-i18next": "^15.4.1", "swr": "^2.2.5", "swr-openapi": "^5.1.0", "tailwind-merge": "^2.5.5", diff --git a/src/app.tsx b/src/app.tsx index f9b5814..df120f4 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,15 +1,17 @@ import { Plus, SearchMd, X } from "@untitled-ui/icons-react"; import { AnimatePresence, motion, MotionConfig } from "motion/react"; import React from "react"; +import Footer from "./components/footer"; import { StationItem } from "./components/station-item"; +import { useLanguage } from "./hooks/use-language"; import { useMeasure } from "./hooks/use-measure"; import { usePersistedState } from "./hooks/use-persisted-state"; import { useStations } from "./hooks/use-stations"; import { cn, createKey } from "./utils"; -import Footer from "./components/footer"; export function App() { const [state, setState] = React.useState<"VIEW" | "SEARCH" | "ADD">("VIEW"); + const [ref, height] = useMeasure(); const [addSearch, setAddSearch] = React.useState(""); @@ -27,6 +29,7 @@ export function App() { ]); const { data: stations } = useStations(); + const { t } = useLanguage(); /* useOnClickOutside(ref, () => { if (state !== "ADD") { @@ -75,7 +78,7 @@ export function App() { setViewSearch(e.target.value)} className="text-md w-full bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-transparent" @@ -220,7 +223,7 @@ export function App() { setAddSearch(e.target.value)} value={addSearch} @@ -275,7 +278,9 @@ export function App() { return (
- Stasiun {addSearch} tidak dapat ditemukan + {t("Stasiun {{addSearch}} tidak dapat ditemukan", { + addSearch, + })}
); @@ -370,10 +375,10 @@ export function App() { return (

- Stasiun belum ditambahkan + {t("Stasiun belum ditambahkan")}

- Tambahkan stasiun dengan menekan tombol + + {t("Tambahkan stasiun dengan menekan tombol")} +
); diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 3ba5a9d..316384c 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,120 +1,153 @@ +import { useLanguage } from "@/hooks/use-language"; +import { cn } from "@/utils"; import { Mail01 } from "@untitled-ui/icons-react"; -const Footer = () => ( -
-

- Made as an act of belief that public transportation data should be - publicly accessible -

+const Footer = () => { + const { t, language, switchLanguage } = useLanguage(); -
- - +

+ {t( + "Dibuat sebagai bentuk keyakinan bahwa data transportasi umum seharusnya dapat diakses oleh semua orang.", + )} +

+ +
+ + + + GitHub + + + + + + Discord + + + + + + LinkedIn + + + + + + +
-
- - Feedback - -

- - Status - -

- - API - -

- - Analytics - +
-
-); + ); +}; export default Footer; diff --git a/src/components/station-item.tsx b/src/components/station-item.tsx index f594026..2bbe718 100644 --- a/src/components/station-item.tsx +++ b/src/components/station-item.tsx @@ -1,10 +1,12 @@ import { Loading01 } from "@untitled-ui/icons-react"; import React from "react"; +import { useTranslation } from "react-i18next"; import { Accordion } from ".//ui"; import { useSchedule } from "../hooks/use-schedule"; import { useStation } from "../hooks/use-station"; import { components } from "../schema"; import { cn, formatDateToTime, formatRelativeToNow } from "../utils"; +import { Language } from "../libs/i18n/types"; export type GroupedSchedule = Record< string, @@ -21,17 +23,21 @@ const ScheduleLine = ({ groupedSchedule: GroupedSchedule; }) => { const hook = useStation(destKey); + const { + t, + i18n: { language }, + } = useTranslation(); const station = hook.data?.data; return (

- {groupedSchedule[lineKey]?.[destKey]?.[0]?.line} + {t(groupedSchedule[lineKey]?.[destKey]?.[0]?.line)}

-

Arah menuju

-

Berangkat pukul

+

{t("Arah menuju")}

+

{t("Berangkat pukul")}

@@ -53,6 +59,7 @@ const ScheduleLine = ({

{formatDateToTime( groupedSchedule[lineKey]?.[destKey]?.[0]?.departs_at, + language as Language, )}

@@ -73,13 +80,13 @@ const ScheduleLine = ({ return moreSchedules.length > 0 ? ( moreSchedules.length < 4 ? (
-

Jam berikutnya

+

{t("Jam berikutnya")}

{moreSchedules.map((train) => (

- {formatDateToTime(train.departs_at)} + {formatDateToTime(train.departs_at, language as Language)}

{formatRelativeToNow(train.departs_at)} @@ -98,13 +105,16 @@ const ScheduleLine = ({

-

Jam berikutnya

+

{t("Jam berikutnya")}

{moreSchedules.map((train) => (

- {formatDateToTime(train.departs_at)} + {formatDateToTime( + train.departs_at, + language as Language, + )}

{formatRelativeToNow(train.departs_at)} @@ -121,7 +131,10 @@ const ScheduleLine = ({ .map((train) => (

- {formatDateToTime(train.departs_at)} + {formatDateToTime( + train.departs_at, + language as Language, + )}

{formatRelativeToNow(train.departs_at)} @@ -142,6 +155,7 @@ const ScheduleLine = ({ export const StationItem = ({ stationId }: { stationId: string }) => { const { data: schedules, isLoading, isValidating } = useSchedule(stationId); + const { t } = useTranslation(); const groupedSchedule = React.useMemo(() => { const data = schedules?.data; @@ -182,7 +196,7 @@ export const StationItem = ({ stationId }: { stationId: string }) => {

-

Stasiun

+

{t("Stasiun")}

{station ? (

@@ -222,7 +236,7 @@ export const StationItem = ({ stationId }: { stationId: string }) => {

) : isEmpty ? (

- Jadwal kereta api tidak tersedia. Cek lagi pada esok hari. + {t("Jadwal kereta api tidak tersedia. Cek lagi pada esok hari")}.

) : groupedSchedule ? ( Object.keys(groupedSchedule).map((lineKey, id, arr) => ( diff --git a/src/hooks/use-language.ts b/src/hooks/use-language.ts new file mode 100644 index 0000000..e1c4ff7 --- /dev/null +++ b/src/hooks/use-language.ts @@ -0,0 +1,26 @@ +import { setDefaultOptions } from "date-fns"; +import { enUS, id } from "date-fns/locale"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import "../libs/i18n/config"; + +export type Language = "en" | "id"; + +export const useLanguage = ( + props: { + onSwitchLanguage?: (val: Language) => void; + } = {}, +) => { + const [language, setLanguage] = React.useState("id"); + const { t, i18n } = useTranslation(); + + const switchLanguage = (val: Language) => { + setLanguage(val); + i18n.changeLanguage(val); + // Sync date-fns locale with language + setDefaultOptions({ locale: val === "en" ? enUS : id }); + props.onSwitchLanguage?.(val); + }; + + return { language, switchLanguage, t }; +}; diff --git a/src/libs/i18n/config.ts b/src/libs/i18n/config.ts new file mode 100644 index 0000000..2b12ef5 --- /dev/null +++ b/src/libs/i18n/config.ts @@ -0,0 +1,15 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./en/translation.json"; + +i18n.use(initReactI18next).init({ + lng: "id", + interpolation: { + escapeValue: false, + }, + resources: { + en: { translation: en }, + }, +}); + +export default i18n; diff --git a/src/libs/i18n/en/translation.json b/src/libs/i18n/en/translation.json new file mode 100644 index 0000000..89c426f --- /dev/null +++ b/src/libs/i18n/en/translation.json @@ -0,0 +1,13 @@ +{ + "Arah menuju": "Directions to", + "Berangkat pukul": "Depart at", + "Cari stasiun": "Search station", + "Jadwal kereta api tidak tersedia. Cek lagi pada esok hari": "Train schedule is not available. Please check again tomorrow", + "Jam berikutnya": "Next hour", + "Stasiun": "Station", + "Stasiun belum ditambahkan": "Station not added yet", + "Stasiun {{addSearch}} tidak dapat ditemukan": "Station {{addSearch}} cannot be found", + "Tambahkan stasiun dengan menekan tombol": "Add a station by pressing the button", + "DINAS RANGKAIAN KRL (TIDAK ANGKUT PENUMPANG)": "KRL TRAIN SERVICE (NOT TRANSPORTING PASSENGERS)", + "Dibuat sebagai bentuk keyakinan bahwa data transportasi umum seharusnya dapat diakses oleh semua orang.": "Made as an act of belief that public transportation data should be publicly accessible." +} diff --git a/src/libs/i18n/types.ts b/src/libs/i18n/types.ts new file mode 100644 index 0000000..6057bcb --- /dev/null +++ b/src/libs/i18n/types.ts @@ -0,0 +1 @@ +export type Language = "id" | "en"; diff --git a/src/utils/time.ts b/src/utils/time.ts index ac31f50..3751337 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,5 +1,5 @@ -import { formatDistanceToNow } from "date-fns"; -import { id } from "date-fns/locale"; +import { format, formatDistanceToNow } from "date-fns"; +import { Language } from "../libs/i18n/types"; export const formatRelativeToNow = (time: string) => { const baseDate = new Date(time); @@ -9,14 +9,10 @@ export const formatRelativeToNow = (time: string) => { baseDate.getMinutes(), ); - return formatDistanceToNow(todayDate, { - locale: id, - }); + return formatDistanceToNow(todayDate); }; -export const formatDateToTime = (baseDate: string) => { +export const formatDateToTime = (baseDate: string, lang: Language = "id") => { const date = new Date(baseDate); - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - return `${hours}:${minutes}`; + return format(date, lang === "id" ? "HH:mm" : "hh:mmaaa"); };