diff --git a/.gitignore b/.gitignore index fd3dbb5..8edcc7c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + + +.cache/* \ No newline at end of file diff --git a/README.md b/README.md index dc9aa5d..a7aa9f8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,16 @@ A file `translations.json` is downloaded from the CMS every time you run a command. This allows Typescript to use it's type data for autocompleting and build-time verifying translation keys. +## CMS cache + +On the first run, the project fetches data from the CMS. This data is stored in `.cache`, which is used for subsequent fetches. The cache TTL is 1 hour. +When the `Translation` collection is fetched, a type file is also generated in the cache directory, which holds the type of the translation keys. If the type file does not exist, the translation key type is inferred as `string`. For example + +``` +const t = useTranslate(); +const string = t("doesnt:exist"); // does not fail if there is no type file in the cache +``` + ## License This project contains both code and content. The code of the website is licensed under GPLv3. By content we mean text, image, logos or designs specific to the Satakunta Nation. The content is proprietary. In other words, you can freely use the technical implementation (code, config files) of this website for your purposes, but make your own content. diff --git a/components/Carousel.tsx b/components/Carousel.tsx index a50e609..6ee62b9 100644 --- a/components/Carousel.tsx +++ b/components/Carousel.tsx @@ -1,10 +1,10 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import useTranslate from "@/hooks/useTranslate"; import { EmblaCarouselType, EmblaOptionsType } from "embla-carousel"; import useEmblaCarousel from "embla-carousel-react"; import Image from "next/image"; import Link from "next/link"; import React, { useEffect } from "react"; +import { useTranslate } from "@/hooks/TranslationContext"; import { NextButton, PrevButton, usePrevNextButtons } from "./CarouselArrows"; type PropType = { diff --git a/components/ContactTable.tsx b/components/ContactTable.tsx index 9828e1b..f415f54 100644 --- a/components/ContactTable.tsx +++ b/components/ContactTable.tsx @@ -1,5 +1,4 @@ import { Contact } from "@/lib/cmsClient"; -import useTranslate from "@/hooks/useTranslate"; import { TableContainer, Table, @@ -12,6 +11,7 @@ import { } from "@mui/material"; import styles from "@/styles/contactTable.module.css"; import { useMemo, useState } from "react"; +import { useTranslate } from "@/hooks/TranslationContext"; export type ContactTableProps = { contactData: Contact[]; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 9889d87..a75429e 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -16,8 +16,8 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { useState } from "react"; -import useTranslate from "@/hooks/useTranslate"; import { NavigationLink } from "@/lib/cmsClient"; +import { useTranslate } from "@/hooks/TranslationContext"; import close from "../public/close.svg"; import menu from "../public/menu.svg"; diff --git a/fetchTranslations.ts b/fetchTranslations.ts deleted file mode 100644 index dc86539..0000000 --- a/fetchTranslations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as dotenv from "dotenv"; -dotenv.config({ path: ".env.local" }); -import { writeFile } from "fs/promises"; -import createClient from "./lib/cmsClient"; -import { readItems } from "@directus/sdk"; - -export async function fetchTranslations() { - const client = createClient(); - try { - const translations = await client.request(readItems("Translation")); - // go from [{key: "hello", fi: "Hei", sv: "Hej", en: "Hello"}] to {"hello": {fi: "Hei", sv: "Hej", en: "Hello"}} - const mappedTranslations = translations.reduce( - (map, translation) => ({ - ...map, - [translation.key]: { - fi: translation.fi, - en: translation.en, - sv: translation.sv, - }, - }), - {}, - ); - - await writeFile( - "./hooks/translations.json", - JSON.stringify(mappedTranslations), - ); - console.log("Downloaded and saved translations"); - } catch (error) { - console.log(error); - } -} - -fetchTranslations(); diff --git a/hooks/TranslationContext.tsx b/hooks/TranslationContext.tsx new file mode 100644 index 0000000..7d64daf --- /dev/null +++ b/hooks/TranslationContext.tsx @@ -0,0 +1,47 @@ +import { Translation, TranslationKey } from "@/lib/cmsClient"; +import { createContext, useContext } from "react"; +import { Language, useLanguage } from "../lib/LanguageContext"; + +export const TranslationContext = createContext(null); + +type TranslationProviderProps = { + translations: Translation[]; + children: React.ReactNode; +}; + +export const TranslationProvider = ({ + translations, + children, +}: TranslationProviderProps) => ( + + {children} + +); + +const translate = ( + translations: Translation[], + key: TranslationKey, + language: Language, +): string => { + const translation = translations.find((t) => t.key === key); + + if (translation === undefined) { + throw new Error( + `Could not find translation ${key} (see that it exists in the CMS)`, + ); + } + + return translation[language]; +}; + +export const useTranslate = () => { + const { language } = useLanguage(); + const translations = useContext(TranslationContext); + if (translations === null) { + throw new Error( + "useTranslate failed, make sure TranslationProvider is set", + ); + } + return (key: TranslationKey, languageOverride?: Language) => + translate(translations, key, languageOverride ?? language); +}; diff --git a/hooks/translations.json b/hooks/translations.json deleted file mode 100644 index 5f4f6a2..0000000 --- a/hooks/translations.json +++ /dev/null @@ -1,273 +0,0 @@ -{ - "contact:contactLabel": { - "fi": "Yhteystiedot", - "en": "Contact", - "sv": "Kontakt" - }, - "contact:firstName": { "fi": "Etunimi", "en": "First Name", "sv": "Förnamn" }, - "contact:hallitus": { - "fi": "Hallitus\t", - "en": "Board\t", - "sv": "Styrelsen\t" - }, - "contact:kuraattori": { - "fi": "Kuraattori", - "en": "Curator", - "sv": "Kurator" - }, - "contact:lastName": { - "fi": "Sukunimi", - "en": "Last Name", - "sv": "Efternamn" - }, - "contact:searchLabel": { "fi": "Hae", "en": "Search", "sv": "Sök" }, - "contact:tableLabel": { - "fi": "Yhteystiedot", - "en": "Contact Information", - "sv": "Kontaktuppgifter" - }, - "contact:title": { "fi": "Nimike", "en": "Title", "sv": "Titel" }, - "contact:verkkovastaava": { - "fi": "Verkkovastaava", - "en": "Web Administrator\t", - "sv": "Nätverksansvarige" - }, - "contect:inspehtori": { - "fi": "Inspehtori\t", - "en": "Inspector\t", - "sv": "Inspektor\t" - }, - "general:nation": { - "fi": "Satakuntalainen Osakunta", - "en": "Satakunta Nation", - "sv": "Satakunta Nation" - }, - "general:seeMore": { "fi": "Katso lisää", "en": "See more", "sv": "Se mer" }, - "homepage:calendarLabelEvents": { - "fi": "Tapahtumat", - "en": "Events", - "sv": "Evenemang" - }, - "homepage:calendarLabelMeeting": { - "fi": "Kokoukset", - "en": "Meetings", - "sv": "Möten" - }, - "homepage:calendarLabelSports": { - "fi": "Urheilut", - "en": "Sports", - "sv": "Sport" - }, - "homepage:contactBoardDescription": { - "fi": "Voit ottaa yhteyttä osakunnan hallitukseen sähköpostitse osoitteella hallitus(a)satakuntatalo.fi tai alla olevalla lomakkeella. Halutessasi tavoittaa tietyn virkailijan, virkailijoiden yhteystiedot löytyvät \"Ota yhteyttä\" sivulta.\t ", - "en": "You can contact the board by sending an email to hallitus(a)satakuntatalo.fi or by filling out the form. If you need to get in touch with a specific official you can find their contact info on the \"Contact us\" page.\t \t \t \t", - "sv": "Du kan kontakta styrelsen via e-post på addressen hallitus(a)satakuntatalo.fi eller via formuläret. Du kan även kontakta specifika funktionärer, vars kontaktuppgifter hittas på \"Ta kontakt\" sidan.\t \t \t \t" - }, - "homepage:contactBoardHeader": { - "fi": "Postia hallitukselle", - "en": "Contact the board", - "sv": "Kontakta styrelsen" - }, - "homepage:contactFormButton": { - "fi": "Siiry Lomakkeelle", - "en": "Fill out the form", - "sv": "Fyll i formuläret" - }, - "homepage:eventsCard": { - "fi": "Tapahtumat", - "en": "Events", - "sv": "Evengemang" - }, - "homepage:harassmentFormDescription": { - "fi": "Tällä lomakkeella voit kertoa osakunnalla kokemastasi häirinnästä tai epätasa-arvoisesta kohtelusta. Tiedot käsittelee luottamuksellisesti osakunnan yhdenvertaisuusvastaavana toimiva kuraattori.\t \t \t \t \t", - "en": "With this form, you can report any harassment or unequal treatment you have experienced within the student nation. The curator, who serves as the equality officer, will handle the information confidentially.\t \t \t \t \t", - "sv": "Med detta formulär kan du anmäla trakasserier eller ojämlik behandling du upplevt inom nationen. Kuratorn, som fungerar som nationens jämställdhetsanvarig, behandlar uppgifterna konfidentiellt. \t \t \t \t \t" - }, - "homepage:harassmentFormHeader": { - "fi": "Härintälomake\t", - "en": "Harrassment form\t", - "sv": "Trakasserianmälan\t" - }, - "homepage:heroSectionText": { - "fi": "Ystäviä, tapahtumia ja koti kampissa", - "en": "Friends, events and a home in Kamppi", - "sv": "Vänner, evengemang och hem i kampen" - }, - "homepage:join": { - "fi": "Liity Osakuntaan", - "en": "Join the Nation", - "sv": "Bli medlem" - }, - "homepage:karhunkierrosHeader": { - "fi": "Osakuntalehti Karhunkierros\t", - "en": "Nation Magazine: Karhunkierros\t", - "sv": "Nations tidning: Karhunkierros\t" - }, - "homepage:livingInfoDescription": { - "fi": "Satakuntatalon asuntola sijaitsee loistavalla paikalla aivan Helsingin ydinkeskustan tuntumassa. Eri korkeakoulut ovat hyvien kulkuyhteyksien päässä, ja edulliselle vuokralle saa vastinetta monien etujen muodossa.\t \t \t \t", - "en": "The Satakunta House dormitory is located in a great spot in the middle of Helsinki. The Universities are easily accessible with good transport connections, and rent is affordable with many great benefits included.\t \t \t \t", - "sv": "Satakunta husets studerandebostäder finns i Helsingfors centrum. Högskolorna är lätta att nå på grund av nära förbindelser till kollektivtrafik. Hyran är förmånlig och inkluderar många bra förmåner.\t \t \t \t" - }, - "homepage:livingInfoHeader": { - "fi": "Asuminen Satakuntatalolla", - "en": "Live at the Satakunta house", - "sv": "Bo på Satakunta huset" - }, - "homepage:memberCard": { - "fi": "Liity jäseneksi\t", - "en": "Become a member", - "sv": "Bli medlem" - }, - "homepage:nationInfoCard": { - "fi": "Tietoa osakunnasta", - "en": "Nation Info", - "sv": "Nations info" - }, - "homepage:newsCard": { "fi": "Uutiset", "en": "News", "sv": "Nyheter" }, - "homepage:saatioLinkButtonText": { - "fi": "Satalinnan säätiö\t", - "en": "Satalinna foundation\t", - "sv": "Satalinna Stiftelse\t" - }, - "nationInfo:headerDescription": { - "fi": "Talo Kampissa, mahtavia tapahtumia, hyviä tyyppejä! Tältä sivulta löydät Satakuntalaisesta Osakunnasta tietoa, mitä me teemme ja miten voit liittyä mukaan!\t \t \t \t", - "en": "House in Kamppi, amazing events, great people! On this page you will find information about Satakuntalainen Osakunta, what we do and how you can join us!\t \t \t \t", - "sv": "Hus i Kampen, fantastiska evenemang, fantastiska människor! På den här sidan hittar du information om Satakuntalainen Osakunta, vad vi gör och hur du kan bli medlem hos oss!\t \t \t \t" - }, - "nationInfo:headerTitle": { - "fi": "Tietoa Osakunnasta\t", - "en": "Nation Info\t", - "sv": "Information om Nationen\t" - }, - "nationInfo:howToJoinDescription": { - "fi": "Kiinnostuitko liittymisestä tai haluatko lisätietoja toiminnastamme? Ota yhteyttä jäsenasioiden sihteeriimme osoitteessa jasensihteeri(a)satakuntatalo.fi, tai piipahda toimistollamme osoitteessa Lapinrinne 1 E1 keskiviikkoisin klo 18:00-18:30. Opiskelija-asumiseen liittyvissä kysymyksissä voit olla yhteydessä asuntoasiainhoitokuntaamme osoitteessa ajk(a)satakuntatalo.fi. Jäsenmaksumme on vain 12 € vuodessa, Liittyessäsi jäseneksi saat mahdollisuuden ostaa sähköisen avaimen, jolla pääset kulkemaan Satakuntatalolla, aktiivisella opiskelijatalollamme. Jäsenenä saat vapaasti käyttää yhteisiä tilojamme, kuten keittiötä, TV-huonetta joka pelikonsolien kera,kirjastoa, kuntosalia ja saunaa. Saat myös viikoittain uutiskirjeen tulevista tapahtumista sekä erillisiä viestejä erityistapahtumista. Jäsenenä voit liittyä erilaisiin kerhoihin ja toimikuntiin, osallistua akateemisiin pöytäjuhliin ja nauttia kaikesta muusta, mitä yhteisömme tarjoaa! ", - "en": "Interested in joining or learning more about our activities? Feel free to reach out to our membership secretary at jasensihteeri(a)satakuntatalo.fi, or simply drop by our office at Lapinrinne 1 E1 on Wednesdays between 18:00 and 18:30. For inquiries about student accommodation, please contact our housing board at ajk(a)satakuntatalo.fi. Our annual membership fee is just €12. Becoming a member grants you the chance to get an electronic key to access Satakuntatalo, our vibrant student house. As a member, you’ll have full access to shared spaces like our kitchen, TV room with gaming consoles, a library, a gym and a sauna. You’ll also receive a weekly newsletter with updates on upcoming events, along with additional emails for specific happenings. Once you join, you can participate in various clubs and committees, attend our traditional dinner parties, and enjoy much more of what our community has to offer! ", - "sv": "Vill du bli medlem eller få mer information om våra aktiviteter? Kontakta vår medlemssekreterare på jasensihteeri(a)satakuntatalo.fi, eller besök vårt kontor på Lapinrinne 1 E1 på onsdagar mellan 18:00 och 18:30. För frågor om studentboende, kontakta vår bostadsstyrelse på ajk(a)satakuntatalo.fi. Vår medlemsavgift är endast 12 € per år, och när du blir medlem får du möjlighet att en elektronisk nyckel som ger dig tillgång till Satakuntatalo, vårt aktiva studenthus. Som medlem har du fri tillgång till gemensamma utrymmen som köket, TV-rummet med spelkonsoler, biblioteket, gym och bastu. Du får även ett veckobrev med information om kommande evenemang samt extra meddelanden om specifika händelser. Som medlem kan du gå med i olika klubbar och kommittéer, delta i våra traditionella middagar och njuta av allt annat som vårt gemenskap erbjuder! " - }, - "nationInfo:howToJoinTitle": { - "fi": "Miten voin liittyä?\t \t", - "en": "How do I join?\t \t", - "sv": "Hur blir jag medlem?\t \t" - }, - "nationInfo:whatIsSatoDescription": { - "fi": "Satakuntalainen Osakunta, tuttavallisemmin SatO, kutsuu yhteen Helsinkiin muuttaneiden satakuntalaisten, tai satakuntalaismielisten opiskelijat. Vuonna 1653 perustettu SatO on yksi yliopiston vanhimmista osakunnista, ja sen toiminnan keskus on upea Satakuntatalo aivan Helsingin ytimessä. Tämä ikoninen talo toimii paitsi tapahtuma- ja juhlatilana, myös opiskelijoiden kodikkaana kokoontumispaikkana. Tule mukaan ja koe Helsingissä opiskelun parhaat puolet!\t \t \t \t", - "en": "Satakuntalainen Osakunta, or SatO, brings together students from Satakunta who have moved to Helsinki. Founded in 1653, SatO is one of the oldest nations of the university, and its centre of activity is the magnificent Satakuntatalo in the heart of Helsinki. This iconic house serves not only as an event and party venue, but also as a cosy meeting place for students. Come and experience the best of studying in Helsinki! ", - "sv": "Satakuntalainen Osakunta, eller SatO, samlar studenter från Satakunta som har flyttat till Helsingfors. SatO grundades 1653 och är en av universitetets äldsta institutioner, och dess centrum är den magnifika Satakuntatalo i hjärtat av Helsingfors. Denna ikoniska byggnad fungerar inte bara som en evenemangs- och festlokal utan också som en mysig mötesplats för studenter. Kom och upplev det bästa med att studera i Helsingfors!\t \t \t \t" - }, - "nationInfo:whatIsSatoTitle": { - "fi": "Mikä on Satakuntalainen Osakunta?\t \t", - "en": "What is Satakuntalainen Osakunta?\t \t", - "sv": "Vad är Satakuntalainen Osakunta?\t \t" - }, - "nationInfo:whatWeDoDescription": { - "fi": "Satakuntalaisen Osakunnan jäsenenä pääset osaksi rikasta opiskelijakulttuuria, joka tarjoaa kaikkea akateemisista pöytäjuhlista urheilu- ja kulttuuritapahtumiin. Satakuntatalon opiskelija-asunnot mahdollistavat asumisen lähellä kaikkea, mitä Helsingin keskusta tarjoaa. SatO on täydellinen paikka verkostoitumiseen, uusiin opintoalat läpäiseviin ystävyyssuhteisiin. \t \t \t \t", - "en": "As a member of Satakuntalainen Osakunta, you are part of a vibrant student culture that offers everything from academic dinner parties to sports and cultural events. The student apartments in Satakuntatalo allow you to live right in the heart of Helsinki, close to everything the city has to offer. SatO is the perfect place for networking, building cross-disciplinary friendships.\t \t \t \t", - "sv": "Som medlem i Satakuntalainen Osakunta blir du del av en rik studentkultur som erbjuder allt från akademiska middagar till sport- och kulturevenemang. Studentlägenheterna i Satakuntatalo gör det möjligt att bo mitt i Helsingfors, nära allt vad staden har att erbjuda. SatO är den perfekta platsen för nätverkande, att knyta vänskapsband över ämnesgränser.\t \t \t \t" - }, - "nationInfo:whatWeDoTitle": { - "fi": "Mitä osakunnalla tehdään?\t \t", - "en": "What do you do in a student nation?\t \t", - "sv": "Vad görs på nationen?\t \t" - }, - "nav:archive": { "fi": "Arkisto", "en": "Archive", "sv": "Arkiv" }, - "nav:calendar": { "fi": "Kalenteri", "en": "Calendar", "sv": "Kalender" }, - "nav:contacts": { - "fi": "Ota yhteyttä", - "en": "Contact Us ", - "sv": "Ta kontakt" - }, - "nav:events": { "fi": "Tapahtumat", "en": "Events", "sv": "Evenemang" }, - "nav:forMembers": { - "fi": "Jäsenille", - "en": "For Members", - "sv": "För Medlemmar" - }, - "nav:harassmentForm": { - "fi": "Härintälomake\t", - "en": "Harrassment Form\t", - "sv": "Trakasserianmälan\t" - }, - "nav:home": { "fi": "Etusivu", "en": "Home", "sv": "Framsida" }, - "nav:karhunkierros": { - "fi": "Osakuntalehti Karhunkierros", - "en": "Karhunkierros Magazine", - "sv": "Karhunkierros Tidningen" - }, - "nav:languages": { "fi": "Kielet", "en": "Languages", "sv": "Språk" }, - "nav:nationInfo": { - "fi": "Tietoa osakunnasta", - "en": "Nation Info", - "sv": "Information om Nationen\t" - }, - "nav:news": { "fi": "Uutiset", "en": "News", "sv": "Nyheter" }, - "nav:officialDocuments": { - "fi": "Viralliset Dokumentit", - "en": "Official Documents", - "sv": "Officiella Dokument" - }, - "nav:rental": { - "fi": "Satalinnan säätiön verkkosivut", - "en": "Säätiö Rental Page", - "sv": "Satalinnan säätiös webbsida" - }, - "officialDocuments:dormitoryText": { - "fi": "Satalinnan Säätiön Asuntoloiden ohjesääntö\t", - "en": "Satalinna foundation dormitory guidelines\t", - "sv": "Satalinna stiftelse studerandebostads reglering\t" - }, - "officialDocuments:dormitoryTitle": { - "fi": "Asuntolatoiminta\t", - "en": "Dormitory Regulations\t", - "sv": "Internatverksamheten\t" - }, - "officialDocuments:environment": { - "fi": "Osakunnan ympäristösuunnitelma\t", - "en": "The Nations environmental plan\t", - "sv": "Nationens miljöplan\t" - }, - "officialDocuments:equalityPlan": { - "fi": "Osakunnan yhdenvertaisuussuunnitelma\t", - "en": "The nations equality plan\t", - "sv": "Nationens Jämställdighetsplan\t" - }, - "officialDocuments:foundingRules": { - "fi": "Osakuntatalon aikaiset säänöt\t", - "en": "The Nation house's previous rules\t", - "sv": "Nationshusets tidigare regler\t" - }, - "officialDocuments:oldRegulations": { - "fi": "Vanhat ohjesäännöt\t", - "en": "Old regulations\t", - "sv": "Gamla regleringar\t" - }, - "officialDocuments:oldRules": { - "fi": "Vanhat säännöt\t", - "en": "Old Rules\t", - "sv": "Gamla regler\t" - }, - "officialDocuments:otherDocuments": { - "fi": "Muut dokumentit\t", - "en": "Other documents\t", - "sv": "Andra dokument\t" - }, - "officialDocuments:regulations": { - "fi": "Ohjesäännöt\t", - "en": "Regulations\t", - "sv": "Regleringar\t" - }, - "officialDocuments:rules": { - "fi": "Säännöt\t", - "en": "Rules\t", - "sv": "Regler\t" - }, - "officialDocuments:safeSpace": { - "fi": "SatO:n turvallisemman tilan periaatteet\t", - "en": "SatO principles for a safer space\t", - "sv": "SatO principer för ett säkrare utrymme\t" - }, - "snackbar:languageChanged": { - "fi": "Hienoa! Kieli on vaihdettu suomeksi.", - "en": "Success! Language set to English.", - "sv": "Framgång! Språk inställt på svenska." - } -} diff --git a/hooks/useTranslate.test.tsx b/hooks/useTranslate.test.tsx index 0603fac..caad99d 100644 --- a/hooks/useTranslate.test.tsx +++ b/hooks/useTranslate.test.tsx @@ -1,16 +1,25 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, renderHook } from "@testing-library/react"; -import useTranslate from "./useTranslate"; import { Language, LanguageContext } from "../lib/LanguageContext"; +import { TranslationProvider, useTranslate } from "./TranslationContext"; describe("useTranslate", () => { const wrapper = (language: Language) => - // eslint-disable-next-line react/display-name ({ children }: any) => ( {} }}> - {" "} - {children}{" "} + + {children} + ); @@ -40,17 +49,18 @@ describe("useTranslate", () => { }); it("fails gracefully if LanguageContext is not provided", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); const Component = () => { const t = useTranslate(); return
{t("general:nation")}
; }; - expect(() => render()).toThrow(); + expect(() => render()).toThrow(); }); it("fails gracefully with a wrong key", () => { const t = renderUseTranslate("fi"); - expect(() => t("hello" as any)).toThrow(); + expect(() => t("hello" as any)).toThrow("Could not find translation hello"); }); }); diff --git a/hooks/useTranslate.ts b/hooks/useTranslate.ts deleted file mode 100644 index e72a6b9..0000000 --- a/hooks/useTranslate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import translations from "./translations.json"; -import { Language, useLanguage } from "../lib/LanguageContext"; - -export type TranslationKey = keyof typeof translations; - -const translate = (key: TranslationKey, language: Language): string => { - const translation = translations[key]; - - if (translation === undefined) { - throw new Error( - `Could not find translation ${key} (try running "npm run fetchTranslations")`, - ); - } - - return translation[language]; -}; - -const useTranslate = () => { - const { language } = useLanguage(); - return (key: TranslationKey, languageOverride?: Language) => - translate(key, languageOverride ?? language); -}; - -export default useTranslate; diff --git a/lib/LanguageContext.tsx b/lib/LanguageContext.tsx index a4ee28c..fc84b6e 100644 --- a/lib/LanguageContext.tsx +++ b/lib/LanguageContext.tsx @@ -39,7 +39,7 @@ export const useLanguage = () => { const ctx = useContext(LanguageContext); if (ctx === null) { - throw new Error("could not find LanguageContext"); + throw new Error("useLanguage failed, make sure LanguageProvider is set"); } return ctx; diff --git a/lib/cmsClient.ts b/lib/cmsClient.ts index 76b2fc5..5356015 100644 --- a/lib/cmsClient.ts +++ b/lib/cmsClient.ts @@ -1,5 +1,8 @@ -import { TranslationKey } from "@/hooks/useTranslate"; -import { createDirectus, rest } from "@directus/sdk"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { createDirectus, readItems, rest } from "@directus/sdk"; +// @ts-ignore: This file is generated on the first run. +import type { TranslationKey as CachedTranslationKey } from "@/.cache/Translation"; type Schema = { NavigationLink: NavigationLink[]; @@ -21,9 +24,6 @@ export type Contact = { id: number; }; -/* - * You shouldnt use this type, use the better typed one in useTranslate.tsx instead - */ export type Translation = { key: string; fi: string; @@ -31,11 +31,77 @@ export type Translation = { sv: string; }; +export type TranslationKey = CachedTranslationKey extends any + ? string + : CachedTranslationKey; + +const CACHE_PATH = "./.cache"; +const CACHE_TTL = 3600; + +type CacheEntry = { + data: any; + expires: number; +}; + +const getCache = async (key: string) => { + const cacheFilePath = path.resolve(CACHE_PATH, `${key}.json`); + try { + const entry = JSON.parse( + await fs.readFile(cacheFilePath, "utf-8"), + ) as CacheEntry; + if (entry.expires < Date.now()) { + return null; + } + + return entry.data; + } catch (error) { + return null; + } +}; + +const setCache = async (key: string, data: any) => { + const cacheFilePath = path.resolve(CACHE_PATH, `${key}.json`); + const entry: CacheEntry = { + data, + expires: Date.now() + CACHE_TTL * 1000, + }; + + await fs.mkdir(CACHE_PATH, { recursive: true }); + await fs.writeFile(cacheFilePath, JSON.stringify(entry)); +}; + +const translationTypeFile = (translations: Translation[]) => ` +// This file is generated by cmsClient.ts +export type TranslationKey = + ${translations.map((t) => `"${t.key}"`).join("\n\t| ")}; +`; + const createClient = () => { if (process.env.DIRECTUS_URL === undefined) { throw Error("Environment variable DIRECTUS_URL not defined"); } - return createDirectus(process.env.DIRECTUS_URL).with(rest()); + const client = createDirectus(process.env.DIRECTUS_URL).with(rest()); + + return { + getCollection: async ( + collection: Collection, + ): Promise => { + const cachedResponse = await getCache(collection); + if (cachedResponse) { + return cachedResponse; + } + + const response = await client.request(readItems(collection)); + await setCache(collection, response); + if (collection === "Translation") { + await fs.writeFile( + path.resolve(CACHE_PATH, "Translation.ts"), + translationTypeFile(response as Translation[]), + ); + } + return response as Schema[Collection]; + }, + }; }; export default createClient; diff --git a/lib/withTranslations.tsx b/lib/withTranslations.tsx new file mode 100644 index 0000000..26417cd --- /dev/null +++ b/lib/withTranslations.tsx @@ -0,0 +1,21 @@ +import { TranslationProvider } from "@/hooks/TranslationContext"; +import { Translation } from "./cmsClient"; + +type PropsWithTranslations = T & { + translations: Translation[]; +}; + +function withTranslations( + PageComponent: React.ComponentType, +) { + return function WithTranslation(props: PropsWithTranslations) { + const { translations, ...rest } = props; + return ( + + + + ); + }; +} + +export default withTranslations; diff --git a/package.json b/package.json index 27ad6dd..856133c 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "format": "prettier . --write", "test:watch": "vitest", "test": "vitest run", - "fetchTranslations": "tsx fetchTranslations.ts && prettier hooks/translations.json --write", - "prepare": "husky && npm run fetchTranslations" + "prepare": "husky" }, "dependencies": { "@directus/sdk": "^17.0.0", diff --git a/pages/archive.tsx b/pages/archive.tsx index 29f8af7..7492895 100644 --- a/pages/archive.tsx +++ b/pages/archive.tsx @@ -2,13 +2,12 @@ import HorizontalCard from "@/components/HorizontalCard"; import Navbar, { NavbarProps } from "@/components/Navbar"; import createClient from "@/lib/cmsClient"; import styles from "@/styles/archive.module.css"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { diff --git a/pages/calendar.tsx b/pages/calendar.tsx index 08ae909..f81dae1 100644 --- a/pages/calendar.tsx +++ b/pages/calendar.tsx @@ -2,13 +2,12 @@ import MonthCalendar from "@/components/MonthCalendar"; import Navbar, { NavbarProps } from "@/components/Navbar"; import createClient from "@/lib/cmsClient"; import styles from "@/styles/calendar.module.css"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { diff --git a/pages/contacts.tsx b/pages/contacts.tsx index 33c31e4..184a770 100644 --- a/pages/contacts.tsx +++ b/pages/contacts.tsx @@ -1,14 +1,13 @@ import Navbar, { NavbarProps } from "@/components/Navbar"; import ContactTable, { ContactTableProps } from "@/components/ContactTable"; import createClient from "@/lib/cmsClient"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); - const contactData = await client.request(readItems("Contact")); + const links = await client.getCollection("NavigationLink"); + const contactData = await client.getCollection("Contact"); return { props: { navBar: { diff --git a/pages/events.tsx b/pages/events.tsx index f9817e4..6c4bea8 100644 --- a/pages/events.tsx +++ b/pages/events.tsx @@ -3,13 +3,12 @@ import HorizontalCard from "@/components/HorizontalCard"; import Navbar, { NavbarProps } from "@/components/Navbar"; import createClient from "@/lib/cmsClient"; import styles from "@/styles/events.module.css"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { diff --git a/pages/harassment-form.tsx b/pages/harassment-form.tsx index ea90d56..22f219c 100644 --- a/pages/harassment-form.tsx +++ b/pages/harassment-form.tsx @@ -1,16 +1,13 @@ import HarassmentForm from "@/components/HarassmentForm"; import Navbar, { NavbarProps } from "@/components/Navbar"; import createClient from "@/lib/cmsClient"; +import withTranslations from "@/lib/withTranslations"; import styles from "@/styles/harassmentForm.module.css"; -import { readItems } from "@directus/sdk"; -import { GetStaticProps } from "next"; import Head from "next/head"; -export const getStaticProps: GetStaticProps< - HarassmentFormPageProps -> = async () => { +export const getStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { @@ -24,7 +21,7 @@ type HarassmentFormPageProps = { navBar: NavbarProps; }; -export default function News({ navBar }: HarassmentFormPageProps) { +function HarassmentFormPage({ navBar }: HarassmentFormPageProps) { return ( <> @@ -53,3 +50,5 @@ export default function News({ navBar }: HarassmentFormPageProps) { ); } + +export default withTranslations(HarassmentFormPage); diff --git a/pages/index.tsx b/pages/index.tsx index 173a080..8ac4cf3 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -3,17 +3,16 @@ import Carousel from "@/components/Carousel"; import Navbar, { NavbarProps } from "@/components/Navbar"; import VerticalCard from "@/components/VerticalCard"; import WeekCalendar from "@/components/WeekCalendar"; -import useTranslate from "@/hooks/useTranslate"; -import createClient from "@/lib/cmsClient"; +import createClient, { Translation } from "@/lib/cmsClient"; import { useLanguage } from "@/lib/LanguageContext"; import styles from "@/styles/Home.module.css"; -import { readItems } from "@directus/sdk"; import { Button } from "@mui/material"; import { EmblaOptionsType } from "embla-carousel"; import { GetStaticProps } from "next"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; +import { TranslationProvider, useTranslate } from "@/hooks/TranslationContext"; import aino from "../public/aino.png"; import cAside from "../public/contact-aside.png"; @@ -23,9 +22,11 @@ const SLIDES = Array.from(Array(SLIDE_COUNT).keys()); export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const translations = await client.getCollection("Translation"); + const links = await client.getCollection("NavigationLink"); return { props: { + translations, navBar: { links, }, @@ -33,13 +34,13 @@ export const getStaticProps: GetStaticProps = async () => { }; }; -type HomePageProps = { +type HomeContentProps = { navBar: NavbarProps; }; -export default function Home({ navBar }: HomePageProps) { - const t = useTranslate(); +const HomeContent = ({ navBar }: HomeContentProps) => { const { language } = useLanguage(); + const t = useTranslate(); return ( <> @@ -188,4 +189,16 @@ export default function Home({ navBar }: HomePageProps) { ); +}; + +type HomePageProps = HomeContentProps & { + translations: Translation[]; +}; + +export default function Home({ navBar, translations }: HomePageProps) { + return ( + + + + ); } diff --git a/pages/karhunkierros.tsx b/pages/karhunkierros.tsx index 5b49b31..1d0013d 100644 --- a/pages/karhunkierros.tsx +++ b/pages/karhunkierros.tsx @@ -3,7 +3,6 @@ import Navbar, { NavbarProps } from "@/components/Navbar"; import VerticalCard from "@/components/VerticalCard"; import createClient from "@/lib/cmsClient"; import styles from "@/styles/karhunkierros.module.css"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; @@ -11,7 +10,7 @@ export const getStaticProps: GetStaticProps< KarhunkierrosPageProps > = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { diff --git a/pages/nation-info.tsx b/pages/nation-info.tsx index 3eaa004..37ac104 100644 --- a/pages/nation-info.tsx +++ b/pages/nation-info.tsx @@ -1,32 +1,33 @@ import Navbar, { NavbarProps } from "@/components/Navbar"; -import useTranslate from "@/hooks/useTranslate"; -import createClient from "@/lib/cmsClient"; import styles from "@/styles/nation-info.module.css"; -import { readItems } from "@directus/sdk"; import { Button } from "@mui/material"; import { GetStaticProps } from "next"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; +import { TranslationProvider, useTranslate } from "@/hooks/TranslationContext"; +import createClient, { Translation } from "@/lib/cmsClient"; import Placeholder from "../public/Placeholder_1.png"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); + const translations = await client.getCollection("Translation"); return { props: { navBar: { links, }, + translations, }, }; }; -type NationInfoPageProps = { +type NationInfoContentProps = { navBar: NavbarProps; }; -export default function NationInfo({ navBar }: NationInfoPageProps) { +const NationInfoContent = ({ navBar }: NationInfoContentProps) => { const t = useTranslate(); return ( @@ -92,4 +93,20 @@ export default function NationInfo({ navBar }: NationInfoPageProps) { ); +}; + +type NationInfoPageProps = { + navBar: NavbarProps; + translations: Translation[]; +}; + +export default function NationInfo({ + navBar, + translations, +}: NationInfoPageProps) { + return ( + + + + ); } diff --git a/pages/news.tsx b/pages/news.tsx index ba16018..c1e679c 100644 --- a/pages/news.tsx +++ b/pages/news.tsx @@ -2,13 +2,12 @@ import Navbar, { NavbarProps } from "@/components/Navbar"; import NewsCard from "@/components/NewsCard"; import createClient from "@/lib/cmsClient"; import styles from "@/styles/news.module.css"; -import { readItems } from "@directus/sdk"; import { GetStaticProps } from "next"; import Head from "next/head"; export const getStaticProps: GetStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); return { props: { navBar: { diff --git a/pages/official-documents.tsx b/pages/official-documents.tsx index 369bf35..06e3c0e 100644 --- a/pages/official-documents.tsx +++ b/pages/official-documents.tsx @@ -1,35 +1,31 @@ /* eslint-disable jsx-a11y/anchor-is-valid -- Disable because of a lot of placeholder hrefs */ import Navbar, { NavbarProps } from "@/components/Navbar"; -import useTranslate from "@/hooks/useTranslate"; +import { useTranslate } from "@/hooks/TranslationContext"; import createClient from "@/lib/cmsClient"; +import withTranslations from "@/lib/withTranslations"; import styles from "@/styles/official-documents.module.css"; -import { readItems } from "@directus/sdk"; import { List, ListItem, ListSubheader } from "@mui/material"; -import { GetStaticProps } from "next"; import Head from "next/head"; import Link from "next/link"; -export const getStaticProps: GetStaticProps< - OfficialDocumentsPageProps -> = async () => { +export const getStaticProps = async () => { const client = createClient(); - const links = await client.request(readItems("NavigationLink")); + const links = await client.getCollection("NavigationLink"); + const translations = await client.getCollection("Translation"); return { props: { navBar: { links, }, + translations, }, }; }; -type OfficialDocumentsPageProps = { +type OfficialDocumentsProps = { navBar: NavbarProps; }; - -export default function OfficialDocuments({ - navBar, -}: OfficialDocumentsPageProps) { +function OfficialDocuments({ navBar }: OfficialDocumentsProps) { const t = useTranslate(); return ( @@ -223,3 +219,5 @@ export default function OfficialDocuments({ ); } + +export default withTranslations(OfficialDocuments);