Full Code of pveyes/katla for AI

main a5195dfc488c cached
69 files
176.7 KB
54.4k tokens
171 symbols
1 requests
Download .txt
Repository: pveyes/katla
Branch: main
Commit: a5195dfc488c
Files: 69
Total size: 176.7 KB

Directory structure:
gitextract_1iyklgqu/

├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── test.yml
│       └── update.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .prettierignore
├── .scripts/
│   ├── answers.csv
│   ├── update.js
│   └── whitelist.csv
├── .vscode/
│   └── settings.json
├── README.md
├── components/
│   ├── Alert.tsx
│   ├── App.tsx
│   ├── Board.tsx
│   ├── Container.tsx
│   ├── EmojiRain.tsx
│   ├── EmojiSelector.tsx
│   ├── Header.tsx
│   ├── HeadingWithNum.tsx
│   ├── HelpModal.tsx
│   ├── Keyboard.tsx
│   ├── KeyboardButton.tsx
│   ├── Link.tsx
│   ├── LiveStatsModal.tsx
│   ├── Modal.tsx
│   ├── SettingsModal.tsx
│   ├── SponsorshipFooter.tsx
│   ├── StatsModal.tsx
│   └── Tile.tsx
├── env.dev
├── jest.config.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages/
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── _error.js
│   ├── api/
│   │   ├── define/
│   │   │   └── [word].ts
│   │   ├── live.ts
│   │   └── words.ts
│   ├── arsip/
│   │   ├── [num].tsx
│   │   └── index.tsx
│   ├── bantuan.tsx
│   ├── index.tsx
│   └── lawan.tsx
├── postcss.config.js
├── public/
│   ├── ads.txt
│   └── google563f40b6a67a6d75.html
├── sentry.client.config.js
├── sentry.properties
├── sentry.server.config.js
├── styles/
│   └── globals.css
├── tailwind.config.js
├── tsconfig.json
└── utils/
    ├── __tests__/
    │   ├── answer.test.js
    │   └── game.test.js
    ├── animation.ts
    ├── answers.ts
    ├── browser.ts
    ├── codec.ts
    ├── constants.ts
    ├── fetcher.ts
    ├── formatter.ts
    ├── game.ts
    ├── liveGame.ts
    ├── message.ts
    ├── tracking.ts
    ├── types.ts
    └── useStoredState.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintrc.json
================================================
{
  "extends": "next/core-web-vitals"
}


================================================
FILE: .github/workflows/test.yml
================================================
name: test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js 14.x
        uses: actions/setup-node@v1
        with:
          node-version: "14.x"
      - name: Install & test
        run: |
          yarn install --pure-lockfile --prefer-offline
          yarn test


================================================
FILE: .github/workflows/update.yml
================================================
on:
  schedule:
    # TODO: use new zealand time?
    # Update on every 00:00 GMT+9 / WIT which means 15:00 UTC
    # Also sync timezone change to update.js/TIMEZONE_OFFSET
    - cron: "00 15 * * *"

jobs:
  update_katla:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: actions/setup-node@v1
        with:
          node-version: "14.x"
          registry-url: "https://registry.npmjs.org"
      - name: install deps
        run: yarn install --prefer-offline
      - name: update new word
        run: yarn update
        env:
          SECRET_DATE: ${{ secrets.SECRET_DATE }}
          SECRET_WORD: ${{ secrets.SECRET_WORD }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.scripts/wordle.json
.scripts/nyt.json
.scripts/analysis.js

# vercel
.vercel

# Sentry
.sentryclirc


================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged


================================================
FILE: .prettierignore
================================================
build
coverage
.next
.vercel


================================================
FILE: .scripts/answers.csv
================================================
ganar,pakar,syair,ultah,danur,wisma,ruang,tajin,saksi,palsu,skrin,jeruk,vokal,belok,acian,jenuh,prabu,rahim,eceng,bocah,anyir,robek,bison,gesit,jepit,debus,beruk,porsi,uapan,sabtu,polan,parau,hukum,lawan,setan,butir,makan,rafia,virus,gemuk,comel,lobak,globe,angin,bujur,morse,jumat,undak,belit,keong,hasut,bolos,zebra,irama,rogoh,butuh,virga,waduk,strok,ideal,domba,emoji,dekor,turun,tinja,premi,nadim,kayuh,jujur,sekon,kroco,jeluk,dakwa,kodok,gipsi,volan,frasa,eksak,rompi,tulis,ranah,sadis,angka,surga,lumer,didik,ancai,cuman,yolks,sorot,aksen,garda,ulser,alang,suami,sihir,tekuk,cerai,luang,biner,seduh,hewan,jerih,iklan,caing,gimik,gumam,julur,cecak,pasar,sabar,puyuh,jujut,jiran,april,klise,imbuh,biara,darah,guyur,joker,dekil,zuhur,lerai,mambu,gusar,minum,tutor,gusur,rambu,gojek,pohon,pisau,kueri,kunci,mudah,cacah,cimol,hiena,rumor,kokok,gebuk,sipir,ceruk,duvet,versi,nuget,sepuh,bokeh,takar,andai,luber,apaan,nomor,stres,lepak,lembu,pedal,lebah,porus,cuaca,senin,alibi,berat,ngung,indra,endus,nafsu,kiara,kemon,bunyi,wajah,serah,memar,waras,tentu,lorek,bakul,mudik,folio,lalap,idiot,kimia,curam,licik,baang,cawan,dalih,uwete,tatap,satwa,licin,pasif,wesel,gagal,lotre,jambu,canda,bewok,skrub,celup,debak,resek,ampai,kumur,khiar,lahan,tipis,bihun,penad,jerat,diari,sidik,jurus,satai,kawat,cegah,besan,loket,motif,bibel,mebel,apron,penyu,kakak,luluh,kanda,jejak,radar,desau,nikah,sayup,gowok,jalan,pokok,jilid,obral,bisul,pelek,teguk,jibun,garpu,yakni,perlu,sohib,bahan,culun,sunda,bekel,singa,surau,lelet,patri,susun,datau,orang,didih,gokar,judul,napas,kibor,totol,rakyu,ralat,fatwa,tanah,yakin,batas,rakus,zombi,mahar,istri,batin,manja,bobin,lugas,tukul,serat,absen,impun,windu,kambu,konte,masif,derit,tusuk,pergi,ceker,vasal,cebol,legam,cedok,diksi,teras,kamar,rawon,bidai,andil,putus,gubuk,caima,sapun,pause,belut,kikuk,harta,cagar,mukim,punca,berak,rumus,ompok,kalke,tabur,puyer,urgen,pelik,tuduh,esais,latah,vadem,lotak,hantu,ndoro,gotik,putih,riset,daing,titis,tepuk,genre,pupus,tulus,perut,getas,kalap,cihui,delik,intim,injil,gelar,jarum,tabel,udang,jurik,saleh,gincu,gerha,nihil,jebol,adang,kikis,kebab,latif,derai,sesak,pelit,humor,avgas,bacin,semai,paras,cibuk,cerih,jihad,iring,tante,akrab,ultah,imsak,hotel,union,tikus,kotor,detik,serbu,zumba,kanal,hilal,kubis,makar,tarif,birai,trans,sisik,sukar,retro,freon,jersi,kasui,tahan,cocok,biksu,advis,ihwal,valas,jakun,mafia,cadas,daeng,setel,rasis,firma,gawai,beras,wajib,kelor,timun,kurma,rival,ludes,tajam,lodeh,wulan,krans,cabai,pelor,teteh,polis,heboh,massa,restu,orbit,makna,bongo,valid,tawon,pukul,konde,abjad,salvo,belia,opera,usang,nenek,rancu,benak,jubin,bobol,iblis,hutan,vonis,absah,honje,sinus,datar,darat,emosi,suara,culas,lusuh,cikar,duafa,jimat,nisan,larva,karya,bantu,unjuk,wahyu,coket,salju,lahap,kukuh,kring,benur,siswa,modem,acara,suatu,siaga,sawah,panas,siapa,afair,geger,kecoh,ritme,banda,gawat,warga,kuaci,karat,ovasi,lumut,cetai,lomba,kiper,lever,plang,cekah,kotak,buron,kenyi,pilah,porto,hidup,punya,lelap,sakau,prosa,kakap,lisan,pasak,rukun,semok,untuk,celus,kerja,tebak,retas,korps,ialah,jemur,cerek,terka,udara,swike,bufet,gapai,jumpa,cebur,lurik,cakar,infak,maksi,kemih,imaji,hilir,sasis,bonus,sains,jomlo,senja,bujet,ambai,ejaan,fitur,trofi,biasa,perak,bazar,pecuk,kenap,spasi,subuh,sisir,kukus,vital,lawak,umrah,tomat,katak,pivot,pahat,eboni,betis,tumit,jinak,kapak,nista,helai,tabib,mesti,semen,kribo,logat,resin,visum,uskup,garis,kakus,besit,segar,debum,impor,egois,norma,plano,makro,surya,bubur,insan,ikrar,imbal,rudal,jingu,pulen,kreol,feral,pilih,senil,turut,guyub,fondu,damar,derap,untai,terpa,intan,kawin,ciduk,spons,tifus,hindu,lampu,retak,bedak,jidur,sadar,kromo,judes,ombak,wasit,lahar,jompo,titip,skors,wafat,murid,dekap,paket,badai,opini,jijik,mesra,kapal,indah,tetua,himne,detap,mandi,tidur,urban,imlek,kerap,insaf,tanpa,tutup,oasis,botia,rebah,benua,titik,bruto,gelas,sandi,magma,avtur,tabuh,tinju,ilusi,sinar,savan,bulir,benih,wadah,jemah,tetap,ciput,warna,gagah,gravi,langu,antek,susah,inang,timur,jewer,melek,rilis,cengo,tajir,zakat,demam,perih,lotus,tokek,jawat,pekik,ivori,tensi,nyiur,teori,blitz,hiruk,dahak,agama,dogma,bebop,empuk,nasib,candu,dorbi,liwet,sebum,karst,kover,organ,kekar,tanya,cecer,kemam,siram,slang,cerun,ceria,nanas,kucur,kanan,anani,keris,kedok,balai,urung,habis,bahwa,jawil,junub,halus,keruk,gawar,mania,sumba,encok,tetas,liter,peang,kekal,minim,rubah,amboi,faali,ultah,busik,fosil,siswi,pecat,rimba,gulai,tabik,putri,jegil,jotos,cekik,hawar,wafer,limfa,acuan,cepuk,kulit,danau,yogia,pipih,pecah,takwa,rawan,kuyup,ledek,glosa,punuk,pedas,patah,puasa,orasi,nyawa,lusin,batal,tutur,rouks,buyar,objek,fokus,murka,deguk,cekok,tulat,telat,elips,butut,rindu,tiara,laksa,kroma,bidik,bezit,rugbi,decak,yatim,kunut,runut,sikat,tegel,diska,leleh,pipis,datin,burik,gawir,sanca,kenop,capai,ceter,oktaf,sorak,canai,mogok,tempe,antre,muara,razia,tanda,nyali,rahib,camat,bakda,perca,cadar,jajan,damai,fusia,tiang,kutub,ironi,atlet,ngilu,serta,iuran,payau,netra,kekat,bedil,momok,lelah,wangi,bunda,cadai,kawah,bigot,tiada,tugas,waria,liang,risau,rezim,janda,jumbo,lihai,citra,teror,drama,ceeng,ketat,joget,kurva,vakum,ujung,vista,faksi,jimbe,luwes,andal,khaki,cetus,samar,topik,pejal,telah,taoco,kefir,idiom,lalat,tatah,trafo,ihram,pakan,umami,novel,denim,syekh,ontel,niaga,kucek,fiber,beton,ulang,sause,penat,bakat,yahya,omong,empat,bioma,yonko,pejam,talak,copet,mesem,buaya,gigih,baiat,tanam,oknum,beres,jahat,subur,putra,elung,joglo,menor,buyut,sepah,bugis,fobia,kucup,flora,cacak,sonde,paksa,ranji,animo,wakaf,aktif,semut,bisai,wagyu,besut,koran,comek,admin,siang,merah,rutin,kepak,mantu,gurau,tuyul,wajan,gadis,tekor,cogan,selak,kameo,satin,gebos,sipil,jerit,rokok,seeng,cemas,tewas,susuk,stepa,sedih,boleh,tubir,koala,sibuk,ronde,menit,buani,curai,bodoh,saraf,senar,cerna,tunda,bambu,kinan,iftar,emisi,jenis,kucir,belah,rumit,embun,steno,pompa,taala,bivak,cadel,plong,kerak,siger,hirup,madya,upeti,celah,bagai,tubin,sling,wajik,sosor,kalis,tempa,nyata,cubit,unyil,bulai,rayap,kawal,jelek,gatal,busai,totok,jaksa,angan,novum,setop,tenis,cebok,alaan,taksi,letih,meses,iseng,barai,halwa,bakau,tamat,gempa,behel,kawuk,tatak,dawai,klien,sendu,dahan,tafia,sigma,pines,ajaib,silau,cekal,puisi,nakal,kelus,kawak,selir,gamis,skala,kuasa,janin,ceguk,buket,kepul,panji,jreng,bajik,payah,moral,bleng,becak,kawan,fasad,hujat,tesis,viral,mesiu,merek,sirna,colek,kidal,sahur,lazim,kekau,entok,suaka,panda,cekup,pedis,fardu,kedam,derus,vinil,arsip,namun,nasal,gajih,dower,jodoh,tolan,jawab,cling,beban,cukai,desis,sawer,sayat,oktet,enzim,intip,lepas,paraf,jatuh,warta,intro,setia,ultah,piano,sikap,sanda,pasir,fakta,ukhti,punah,lukis,binar,tiris,terus,entri,datum,sipit,pasal,botol,horas,envoi,pesek,felon,buruh,beguk,mawar,vespa,ujian,salat,rumah,kumuh,pulau,dewan,oliva,usung,nyala,jajah,pedih,onsen,makam,korve,klaim,ricuh,koplo,afiks,tikar,seken,sinis,urine,abaya,abate,bayar,dasar,fasia,gaduh,dassi,erosi,elusi,fatal,fiksi,fungi,demen,encot,gacor,tambi,ruing,awang,medio,murni,betet,keram,laten,sekat,kader,sulam,ligat,tenda,gugur,atase,medik,rusak,disel,kupat,papar,human,japan,ekses,mohon,rebut,kurau,stasi,graha,jamil,sebut,skrip,muram,becik,gerak,makin,bunuh,areal,kirab,untun,kafir,kelab,resan,harga,tegur,kubur,foton,salam,cukur,akmal,asing,nambi,ampar,pakal,figur,pusar,syura,tegak,islam,ngeri,tunik,almon,kecak,asasi,omzet,nazar,bekas,fadil,eksis,sabah,badui,jetis,tapis,kedua,colok,kania,telak,seisi,curah,umbar,masuk,grasi,kopra,fasis,jajal,geber,sisil,detak,lebak,jambe,salut,wukuf,tambo,gajah,cicil,juang,unsur,paruh,lalai,eyang,dekat,kayak,ramen,duduk,inses,alpin,bulus,palak,karel,amino,binal,seruk,angit,kejut,kupas,kuang,duhai,jamur,pihak,kudus,kasia,keluh,pasti,solid,tujuh,keran,rawat,ampel,jatah,kodak,badik,hoaks,crash,pegan,lapar,puspa,yusuf,sabda,natal,afdal,kamus,ranau,ishak,rekap,gabah,metro,gulir,bajak,seksi,rusuk,demak,sukuk,dendi,diare,eweai,kuria,cilik,studi,hadis,sidat,basis,ilham,pikat,loker,jalal,tekat,kipas,sejuk,lapuk,talun,rakit,ketua,bayur,wajar,papan,torsi,hafiz,bacok,sulit,bumbu,roker,jegog,china,acang,tanak,level,randi,donor,talam,ritel,subam,kenek,ganda,curat,gosip,petuk,legit,titan,entah,solok,kabir,pakai,jahit,bahar,bugar,griya,manah,fjord,bulan,buluh,isian,lapis,final,amsal,barak,cacar,induk,gatra,anime,reksa,fulan,tonga,infus,ahang,angel,angsa,cukup,redah,bagan,teuku,butik,nusuk,badau,pagar,gudeg,lemon,kalau,biola,ranti,sowan,lemas,kesah,sakar,utama,copot,pakis,sabuk,abung,zalim,lahab,suwir,tebal,maman,dayak,piton,musik,jasad,bisik,manta,pucuk,karim,perah,asesi,etana,aroma,imbas,lebur,dumbo,dunia,pamit,bedah,gamat,adobe,honor,sikka,bagus,rapuh,walau,tilas,linen,cicit,adika,bakam,tanur,sigap,baret,batuk,sanad,sudah,pulut,serba,penda,apnea,kusam,rabat,patil,lunak,saran,tania,oncom,kelik,ultah,pikir,ajoli,hanif,optik,kriya,pulih,dulur,bahau,lolak,tulip,nipis,gobak,magis,cemar,mitos,aswad,kasur,porno,medan,silam,kumis,utang,obeng,abang,taksu,pinta,pepah,mandu,momen,wujud,surut,coban,cempe,pamah,macet,orion,ludah,dalem,polip,sirah,fakir,tupai,rekor,bahas,madam,setek,ribut,ampat,sudan,cikal,hisab,kicau,komik,semak,viola,kedap,sogan,tukas,tahap,kekeh,cewek,dadar,kamis,yuris,kisam,manga,gardu,lupus,tiner,pandu,feses,audit,tahir,pacar,mepet,gegas,abrar,depot,jamak,kasir,antik,kenya,intel,strip,saham,silat,nalar,zahid,rapel,count,unduh,lezat,arang,sesat,lawar,ambar,ahsan,bawah,etnik,ember,bilah,kurun,kesan,mayam,sereh,ipung,tekun,serut,sebab,kerek,finis,nekat,ander,bajul,gokil


================================================
FILE: .scripts/update.js
================================================
const path = require("path");
const fs = require("fs/promises");
const { Octokit } = require("@octokit/rest");

const answerPath = path.join(__dirname, "answers.csv");
const wordsPath = path.join(__dirname, "whitelist.csv");

// sync with update.yml
const TIMEZONE_OFFSET = 9;

const readCsv = (filePath) =>
  fs.readFile(filePath, "utf-8").then((text) =>
    text
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean)
  );

async function insertWord(answers, answer) {
  if (process.env.GITHUB_ACTIONS) {
    // GH actions
    await writeCommit(answers.concat(answer).join(",") + "\n");
  } else {
    // dev testing
    await fs.writeFile(answerPath, answers.concat(answer).join(","), "utf-8");
  }
}

async function writeCommit(data) {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    throw new Error("Missing GitHub token env in `GITHUB_TOKEN`");
  }

  const FileInfo = {
    owner: "pveyes",
    repo: "katla",
    path: ".scripts/answers.csv",
    sha: "main",
  };

  const octokit = new Octokit({
    baseUrl: "https://api.github.com",
    auth: `token ${token}`,
  });

  const response = await octokit.repos.getContent(FileInfo);
  const { sha } = response.data;

  octokit.repos.createOrUpdateFileContents({
    ...FileInfo,
    message: "Insert new answer",
    content: Buffer.from(data).toString("base64"),
    sha,
    branch: "main",
  });
}

function getMidnightDate() {
  const now = new Date();
  now.setHours(now.getHours() + TIMEZONE_OFFSET);
  const deltaToMidnightMinutes = 60 - now.getMinutes();
  now.setMinutes(now.getMinutes() + deltaToMidnightMinutes);
  return now;
}

async function main() {
  const [usedWords, allWords] = await Promise.all([
    readCsv(answerPath),
    readCsv(wordsPath),
  ]);

  const validWords = allWords.filter((word) => !usedWords.includes(word));

  // use let to allow secret words
  let word = validWords[Math.floor(Math.random() * validWords.length)];
  const secretDate = process.env.SECRET_DATE;
  const secretWord = process.env.SECRET_WORD;
  if (secretDate && secretWord) {
    const date = getMidnightDate();
    const [mm, dd] = secretDate.split("-").map(Number);
    if (date.getDate() == dd && date.getMonth() + 1 === mm) {
      word = secretWord;
    }
  }

  await insertWord(usedWords, word);
  console.log("New word inserted", word);
}

main().catch((error) => {
  console.error("Failed", error);
  process.exit(1);
});


================================================
FILE: .scripts/whitelist.csv
================================================
abadi,abang,abate,abawi,abaya,abbas,abjad,abrar,absah,absen,abung,abuya,acang,acara,acian,acuan,adang,adika,admin,adnan,adobe,adong,aduan,adven,advis,afair,afdal,afiks,agama,agami,agogo,agung,ahang,ahsan,ajaib,ajang,ajoli,akbar,akhir,akmal,akrab,aksen,akses,akson,aktif,aktor,alaan,alami,alang,alarm,album,alias,alibi,alien,allah,almon,alpin,altar,amang,ambah,ambai,ambar,amber,ambil,ambin,amboi,ambon,amina,amino,amorf,ampai,ampar,ampas,ampat,ampel,ampuh,ampun,amsal,anang,anani,ancai,ancam,ancol,andai,andal,andan,andap,andar,ander,andil,andir,aneka,angan,angel,angga,angin,angit,angka,angke,angsa,anime,animo,anjar,ansar,antar,antek,antik,anton,antre,anyam,anyar,anyir,aorta,apaan,apnea,apoge,april,apron,apung,arang,areal,arena,argon,ariel,aries,aroma,arsip,artis,arung,arwah,asasi,asesi,asian,asing,askar,aspek,aswad,asyik,atase,atlas,atlet,atung,audio,audit,aulia,aurat,autis,avgas,avtur,awang,aytek,baang,babad,babak,baban,babar,babat,bacan,bacin,bacok,badai,badak,badal,badan,badar,badau,badik,badui,badur,badut,bagai,bagan,bagas,bagus,bahan,bahar,bahas,bahau,bahri,bahwa,baiat,bajaj,bajak,bajik,bajir,bajul,bakal,bakam,bakar,bakat,bakau,bakda,bakmi,bakso,bakti,bakul,balad,balah,balai,balak,balap,balas,balen,balet,balig,balik,balok,bambu,banda,bando,banga,banta,bantu,banua,banyo,banyu,bapak,barai,barak,baran,baras,barat,barel,baret,barik,baris,barok,baros,basah,basar,basin,basir,basis,basmi,batak,batal,batas,batik,batin,batok,batuk,batur,baung,bawah,bayak,bayam,bayar,bayur,bazar,bebal,beban,bebas,bebek,beber,bebop,becak,becek,becik,becus,bedah,bedak,bedil,beduk,begah,begal,beguk,behel,bejat,bekal,bekam,bekas,bekel,beken,belah,belas,belia,belit,belok,belom,beluk,belum,belut,benak,benar,benci,benda,benih,benua,benur,berak,beras,berat,berau,beres,beruk,besan,besar,beser,besit,besok,besuk,besut,betah,betet,betis,beton,betul,bewok,bezit,biang,biara,biasa,biaya,bibel,bibir,bibit,bidah,bidai,bidak,bidan,bidar,bidik,bigot,bihun,bijak,bijih,bikin,biksu,bilah,bilal,bilas,bilik,binal,binar,biner,binti,biola,bioma,biota,birai,bisai,bisik,bison,bisul,bivak,blang,bleng,blitz,blong,blues,bobin,bobol,bobot,bocah,bocor,bodoh,bogem,bogor,bokap,bokeh,bokor,boleh,bolos,bonar,bongo,bonus,borok,boros,bosan,botak,botia,botol,brata,bruto,buana,buang,buani,buaya,bubar,bubuk,bubur,budak,buduk,bufet,bugar,bugis,bujet,bujuk,bujur,bukan,buket,bukit,bukti,bulai,bulak,bulan,bulat,bulir,buluh,bulus,bumbu,bunda,bunga,buntu,bunuh,bunut,bunyi,buram,burik,buron,bursa,buruh,buruk,busai,busik,busuk,busur,butik,butir,buton,butuh,butut,buyar,buyut,cabai,cabul,cabut,cacah,cacak,cacar,cacat,cadai,cadar,cadas,cadel,cagar,caima,caing,cakap,cakar,cakep,cakra,cakup,calon,camat,canai,canda,candi,candu,capai,capek,caren,carik,carok,carut,catat,catur,cawan,ceban,cebok,cebol,cebur,cecak,cecer,cedok,ceeng,cegah,cegat,ceguk,cekah,cekal,ceker,cekik,cekok,cekup,cekut,celah,celik,celuk,celup,celus,cemar,cemas,cempe,cengi,cengo,cepat,cepek,ceper,cepol,cepuk,cerah,cerai,cerek,ceria,cerih,cerna,ceruk,cerun,cetai,cetak,ceter,cetus,cewek,china,cibuk,cicil,cicit,ciduk,cihui,cikal,cikar,cilik,cilok,cimol,cinde,cinta,cipta,ciput,citra,clear,cling,coban,cocok,cogan,coket,colek,colok,comek,comel,copet,copot,corak,coret,count,cowok,crash,cuaca,cubit,cucun,cucup,cukai,cukup,cukur,culas,culun,cuman,curah,curai,curam,curat,dabal,dabol,dadap,dadar,daeng,dahak,dahan,daing,dairi,dakar,dakon,daksa,dakwa,dalam,dalem,dalih,dalil,damai,damar,danau,danda,dandi,dange,dansa,danur,danus,dapat,dapur,darah,darat,darma,darun,dasar,dassi,datar,datau,datin,datuk,datum,dawai,dawan,dawet,dayah,dayak,debak,debat,debit,debum,debus,debut,decak,dedek,degan,degen,deguk,dekan,dekap,dekat,dekil,dekor,delas,delik,delta,demak,demam,demen,denah,denda,dendi,denim,denok,depak,depan,depok,depot,derai,deran,derap,deras,derbi,derek,derit,derma,derus,desak,desau,desil,desis,detak,detap,detik,deuce,dewan,diare,diari,didih,didik,digit,dikit,diksi,dikte,diler,dinar,dinas,dinda,dipol,disel,diska,disko,diuji,doang,dobel,dodot,dogma,dolar,dolok,domba,dompu,donat,donor,dorbi,dorna,dosen,dosis,dower,doyan,drama,duafa,dubur,duduk,duhai,dukuh,dukun,dulur,dumai,dumbo,dunia,dupak,dusta,dusun,duvet,duwet,eboni,eceng,edisi,egois,ejaan,eksak,ekses,eksil,eksis,elang,elips,elite,elung,elusi,email,emang,emban,embat,ember,embun,emisi,emoji,emosi,empal,empat,empot,empuk,encer,encik,encim,encok,encot,endus,eneng,engku,entah,entit,entok,entri,envoi,enzim,eropa,erosi,esais,ester,etana,etape,etika,etnik,etnis,eweai,eyang,faali,fadil,fajar,fakir,faksi,fakta,falah,falaj,fardu,fasad,fasia,fasih,fasis,fatah,fatal,fatir,fatwa,fault,fauna,felon,feral,feses,fiber,figur,fikih,fiksi,filum,final,finis,firma,fisik,fitri,fitur,fjord,flora,fluks,fobia,fokus,folat,folio,fondu,forte,forum,fosil,foton,foyer,frasa,freon,front,fulan,fulus,fungi,fusia,fyord,gabah,gabus,gacor,gadai,gadis,gaduh,gafur,gagah,gagak,gagal,gagap,gagas,gahar,gajah,gajih,galah,galak,galat,galau,galih,galon,galuh,galur,gamat,gamet,gamis,ganar,ganas,ganda,ganja,ganti,gapai,gaple,garam,garap,garda,gardu,garis,garpu,garut,gatal,gatot,gatra,gaung,gawai,gawan,gawar,gawat,gawir,gayuh,geber,gebos,gebuk,gedor,gegap,gegas,geger,gelak,gelap,gelar,gelas,gelut,gemar,gempa,gemuk,genap,genom,genre,genta,genuk,genus,gerah,gerai,gerak,geram,gerha,geser,gesit,getah,getar,getas,getek,getok,getol,gigih,gigit,gimik,gincu,gipsi,gitar,globe,glosa,gobak,godok,gojek,gokar,gokil,golok,gores,gorok,gorup,gosip,gotik,gowes,gowok,goyah,graha,grasi,gravi,griya,guano,gubuk,gudeg,gugat,gugup,gugur,gugus,gulai,gulam,gulat,gulir,gulma,gumam,gunci,gurah,gurau,gurih,gurit,guruh,gurun,gurur,gusar,gusti,gusur,guyon,guyub,guyur,habib,habil,habis,hadap,hadas,hadir,hadis,hafal,hafiz,hajar,hajat,hakim,halal,halau,halim,halte,halus,halwa,hamba,hamil,hampa,hanif,hantu,hanya,hapus,haram,harap,harga,haris,harta,harum,harun,harus,hasan,hasil,hasta,hasut,hatta,hawar,hayat,hebat,heboh,heiho,helai,hemat,hendi,henry,henti,heran,hewan,hibah,hibur,hidro,hidup,hiena,hijab,hijau,hilal,hilir,himne,hindu,hiruk,hirup,hisab,hitam,hoaks,honje,honor,horas,horor,hotel,hujan,hujat,hukum,human,humor,humus,huruf,hutan,ialah,iblis,ideal,idiom,idiot,idola,idris,iftar,ihram,ihsan,ihwal,iklan,iklim,ikram,ikrar,ilafi,ilahi,ilham,ilusi,ilyas,imago,imaji,imani,imbal,imbas,imbau,imbuh,imlek,impor,impun,imsak,inang,indah,india,indie,indik,indra,induk,infak,infus,ingat,ingin,ingus,injak,injil,input,insaf,insan,inses,intan,intel,intim,intip,intro,ipung,irama,irian,iring,ironi,isbat,iseng,ishak,isian,islah,islam,islan,istri,iuran,ivori,jabal,jabat,jabir,jagat,jagur,jahat,jahil,jahit,jajah,jajak,jajal,jajan,jajar,jaket,jaksa,jakun,jalak,jalal,jalan,jalar,jalen,jalil,jalin,jalur,jamak,jamal,jambe,jambi,jambu,jamil,jamin,jamur,janah,janda,janin,janji,janur,japan,jarak,jarum,jasad,jatah,jatuh,jauza,jawab,jawat,jawil,jebak,jebol,jegal,jegil,jegog,jejak,jelas,jelek,jeluk,jemah,jemur,jenis,jenuh,jepit,jeram,jerat,jerih,jerit,jersi,jeruk,jetis,jewer,jibun,jidur,jihad,jijik,jilid,jimak,jimat,jimbe,jinak,jingu,jiran,jodoh,joget,joglo,johan,johar,joker,jomlo,jompo,jorok,jotos,jreng,juang,juara,jubah,jubin,judes,judul,juita,jujur,jujut,julur,jumat,jumbo,jumpa,junta,junub,jurai,jurik,jurus,kabar,kabau,kabel,kabil,kabin,kabir,kabul,kabur,kabut,kacau,kacer,kacuk,kadal,kadar,kader,kadir,kafir,kaget,kagum,kahar,kaili,kakak,kakao,kakap,kakek,kakus,kalah,kalak,kalam,kalap,kalau,kalbu,kaldu,kalem,kalih,kalis,kalke,kalor,kamal,kamar,kamba,kambu,kameo,kamil,kamis,kamus,kanal,kanan,kanda,kania,kanji,kanya,kapak,kapal,kapan,kapas,kapat,kapok,kapuk,kapur,karah,karam,karat,karel,karet,kargo,karib,karim,karma,karst,kartu,karya,kasap,kasar,kasep,kaset,kasia,kasih,kasim,kasir,kasta,kasui,kasur,kasus,katai,katak,katib,katik,katun,kawah,kawak,kawal,kawan,kawat,kawin,kawuk,kayak,kayuh,kebab,kebal,kebun,kebut,kecak,kecam,kecap,kecil,kecoh,kedah,kedai,kedam,kedap,keder,kedok,kedua,kefir,kejam,kejer,kejut,kekal,kekar,kekat,kekau,kekeh,kelab,kelak,kelam,kelar,kelas,kelih,kelik,kelok,kelor,kelua,keluh,kelus,kemah,kemal,kemam,kemas,kemih,kemis,kemit,kemon,kenal,kenan,kenap,kenek,kenop,kento,kenya,kenyi,keong,kepak,kepal,kepuh,kepul,kerah,kerak,keram,keran,kerap,keras,kerau,kerek,keren,keris,kerja,kerok,keruh,keruk,kesah,kesal,kesan,kesek,kesel,keset,ketan,ketat,ketik,ketok,ketua,ketuk,ketut,khair,khaki,khiar,kiara,kibor,kicau,kidal,kidul,kikil,kikis,kikuk,kilah,kilas,kilat,kilau,kimia,kinan,kipas,kiper,kirab,kiras,kirim,kisah,kisam,kista,kitab,kitir,klaim,klien,klise,kluet,koala,kobra,kocak,kocek,kocok,kodak,kodok,kokoh,kokok,kolak,kolam,kolom,kolot,komen,komet,komik,kompi,konco,konde,konga,konon,konte,konus,koper,koplo,kopra,koran,korea,korek,korps,korve,kosen,kotak,kotor,kover,koyak,krans,kreol,kribo,krida,kring,kriya,kroco,kroma,kromo,kuaci,kuala,kuang,kuasa,kubah,kubik,kubis,kubur,kubus,kucek,kucir,kucup,kucur,kudus,kueri,kukis,kukuh,kukus,kulit,kulon,kulup,kumai,kuman,kumat,kumis,kumuh,kumur,kunci,kunta,kunut,kuota,kupas,kupat,kupon,kurau,kurdi,kuria,kurir,kurma,kursi,kurun,kurus,kurva,kusam,kusir,kusta,kutai,kutip,kutub,kuyup,label,labil,labuh,lafal,lafaz,lahab,lahad,lahan,lahap,lahar,lahat,lahir,lajur,lakon,laksa,lalai,lalap,lalat,lalau,lamak,laman,lamar,lampu,lamun,langi,langu,lapak,lapar,lapis,lapor,lapuk,laras,laris,larut,larva,laser,lasik,lasut,latah,latar,laten,latif,latih,latin,lawah,lawak,lawan,lawar,lawas,layak,layar,layer,lazim,lebah,lebak,lebam,lebar,lebat,lebih,lebur,lecet,ledak,ledek,ledes,legal,legam,legit,legok,leher,lekas,lekat,lekir,lekuk,lelah,lelap,leleh,lelet,lemah,lemak,leman,lemas,lembu,lemon,lensa,lepak,lepas,lepuh,lerai,lerak,letak,letih,level,lever,lewat,lezat,liang,libas,libra,libur,licik,licin,lidah,ligat,lihai,lihat,lilin,lilit,limas,limau,limfa,limit,linda,linen,lines,liong,lipai,lipat,lipid,lirih,lirik,liris,lisan,liter,liwan,liwet,lobak,lodeh,logam,logat,logis,lokal,loker,loket,lokus,lolak,lolos,lomba,longo,lorek,lotak,lotre,lotus,loyal,luang,luber,lubuk,ludah,ludes,lugas,luhur,lukas,lukis,luluh,luluk,lulus,lulut,lumer,lumut,lunak,lunar,lunas,lupus,luput,lurah,lurik,luruh,lurus,lusin,lusuh,lutut,luwes,mabuk,macam,macan,macet,madam,madya,mafia,magis,magma,mahal,mahar,mahdi,mahir,majas,makam,makan,makar,makin,makna,makro,maksi,malah,malam,malas,malik,mamah,mamak,maman,mamat,mambo,mambu,mampu,manah,manda,mandi,mandu,manga,mania,manik,manis,manja,manna,manta,mantu,manuk,mapan,mapor,marah,marak,maras,maret,marga,maria,maros,marut,masak,masif,masih,mason,massa,masuk,matan,matik,matra,maung,mawar,mawas,mayam,mayat,mayor,mayur,mebel,medan,media,medik,medio,medis,megah,megan,mekar,melek,melon,memar,menit,menor,menua,menur,meong,mepet,merah,merak,mercu,merdu,merek,merta,mesem,meses,mesin,mesiu,meski,mesra,mesti,mesum,metal,meter,metro,mewah,mezzo,mikro,milad,milik,milir,mimpi,minat,minda,minim,minor,minta,minum,minus,mirah,mirip,miris,misal,misil,mitos,mitra,mizan,mobil,modal,model,modem,modis,modul,modus,mogok,mohon,molek,molen,molor,momen,momok,moral,moron,morse,motel,motif,motor,muara,mudah,mudik,mufti,mujur,mukim,mulai,mulas,mulia,muluk,mulus,mulut,murad,murah,murai,mural,muram,murid,murka,murni,musik,musim,musti,musuh,nabil,nadim,nadir,nafsu,nahak,nahas,najis,nakal,nalar,nambi,namun,nanah,nanas,nanti,napas,nasab,nasal,nasib,natal,natar,nazar,nazir,ndoro,nduga,necis,nekat,nenek,netra,ngada,ngawi,ngeri,ngilu,ngoyo,ngung,niaga,nihil,nikah,nikel,nilai,nilam,nilon,nimfa,ninik,ninja,nipah,nipis,nisab,nisan,nista,nomor,norma,notes,novel,novum,nuget,nujum,nusuk,nuzul,nyala,nyali,nyata,nyawa,nyepi,nyeri,nyiur,oasis,obeng,objek,oblak,obral,obras,oknum,oktaf,oktet,oleng,oliva,ombak,omega,omong,ompok,omzet,oncom,onsen,ontel,opera,opini,opsen,optik,orang,orasi,orbit,order,organ,orion,osean,ovasi,oyong,pacar,pacet,pacul,padak,padam,padan,padar,padas,padat,padma,pagar,pagon,paham,pahat,pahit,paing,pajak,pakai,pakal,pakan,pakar,pakem,paket,pakis,paksa,paksi,pakta,palak,palar,palas,palem,palet,palit,palka,palsu,pamah,paman,pamer,pamit,panah,panai,panas,panci,panda,pandu,panel,panen,panik,panji,panti,papah,papan,papar,papua,paraf,parah,parak,paras,parau,paris,parit,parsi,paruh,parut,pasai,pasak,pasal,pasan,pasar,paser,pasif,pasir,pasok,pasta,pasti,patah,patar,paten,patih,patil,patin,patok,patri,patuh,patuk,patut,pause,pawai,payah,payau,payet,peang,pecah,pecat,pecel,pecuk,pedal,pedas,pedih,pedis,pegal,pegan,pegon,pejal,pejam,pekan,pekat,pekik,pelak,pelan,pelat,pelek,pelet,pelik,pelit,pelor,penad,penat,penda,penuh,penyu,pepah,pepes,perah,perak,peran,peras,perca,pergi,perih,perlu,peron,perut,pesan,pesat,pesek,pesta,pesut,petak,petes,petik,petir,petis,petra,petuk,piala,piano,piatu,pidie,pihak,pijar,pijat,pikap,pikat,piket,pikir,pikul,pikun,pilah,pilar,pilek,pilih,pilot,pilus,pines,pinta,pintu,pinus,pipih,pipil,pipis,pipit,pisah,pisau,pitak,pitam,piton,pivot,plang,plano,plato,plaza,pleno,plong,pluto,pohon,poise,pojok,poker,poket,pokok,polan,polar,polio,polip,polis,polos,pompa,ponco,popok,porno,poros,porsi,porto,porus,prabu,praja,premi,prima,promo,prosa,puasa,pucat,pucuk,pudar,pudel,puguh,puing,puisi,pujut,pukul,pulas,pulau,pulen,pulih,pulsa,puluh,pulut,punah,punai,punca,pundi,punia,punti,punuk,punya,pupuk,pupus,puput,purba,purut,purwa,pusar,pusat,puspa,putar,putih,putra,putri,putus,putut,puyer,puyuh,qanun,rabat,rabun,racik,racun,radar,raden,radif,radin,radio,rafia,ragam,rahib,rahim,rajab,rajin,rajut,raket,rakit,rakus,rakyu,ralat,ramah,ramai,rambu,ramen,ranah,ranau,rancu,randi,randu,rangu,ranji,ranti,rapak,rapat,rapel,rapor,rapuh,raras,rasau,rasio,rasis,rasul,ratna,ratus,raung,rawan,rawas,rawat,rawit,rawon,rayap,rayon,razia,rebab,rebah,rebus,rebut,receh,redah,redam,redup,regan,regas,rehab,rehal,rehat,rejan,rekam,rekan,rekap,rekor,reksa,remeh,remuk,repot,resah,resan,resek,resep,reses,reset,resik,resin,resmi,resor,resto,restu,retak,retas,retro,reuni,rewel,reyot,rezim,riang,ribet,ribut,ricuh,rijal,rilis,rimba,rindu,risau,riset,ritel,ritme,ritus,rival,robek,roboh,robot,rogoh,roker,roket,rokok,roman,rompi,ronda,ronde,rondo,rotan,rouge,rouks,royal,royan,ruang,rubah,rubik,rubin,rudal,rugbi,ruing,rujak,rujuk,rukun,rumah,rumit,rumor,rumpi,rumus,rungu,runut,rural,rusak,rusuk,rutin,ruwah,ruwet,sabah,saban,sabar,sabda,sabet,sabit,sabtu,sabuk,sabun,sabut,sadap,sadar,sadis,safar,saham,sahih,sahur,sahut,saing,sains,sajak,sakar,sakat,sakau,sakit,saksi,sakti,salaf,salah,salak,salam,salat,saldo,saleh,salem,salep,salib,salim,salin,salip,salju,salon,salto,salur,salut,salvo,samad,saman,samar,samas,samba,sambi,samin,sampo,samsu,sanad,sanak,sanca,sanda,sandi,sanga,santa,santo,sapun,saraf,saran,sarat,saren,sarmi,sasis,satai,satin,satwa,saung,sauri,sause,savan,sawah,sawai,sawer,sawit,sayap,sayat,sayid,sayup,sayur,sebab,sebal,sebar,sebel,sebum,sebut,sedan,sedap,sedia,sedih,sedot,seduh,seeng,segan,segar,segel,sehat,seisi,sejak,sejuk,sekak,sekam,sekar,sekat,seken,sekip,sekon,seksi,sekte,selai,selak,selam,selar,selat,selip,selir,selok,seluk,semai,semak,semen,semok,semua,semur,semut,senam,senar,senat,sendi,sendu,senil,senin,senja,sepah,sepak,sepat,sepen,sepuh,serah,serai,seram,serap,serat,serba,serbu,sereh,serep,seret,serge,serit,serta,seruk,serut,serve,sesak,sesar,sesat,setan,setek,setel,setia,setir,setop,setor,setra,setup,sewot,siaga,siang,siapa,siber,sibuk,sidat,sidik,sifat,sigap,siger,sigma,sihir,sikap,sikat,sikka,siksa,silam,silat,silau,silet,silih,simak,sinar,singa,sinis,sinka,sinom,sinus,sipil,sipir,sipit,siput,sirah,siram,sirih,sirip,sirna,sirop,sisik,sisil,sisir,siswa,siswi,situs,skala,skema,skors,skrin,skrip,skrub,skuad,slang,sling,sobat,sobek,sodok,sodor,sogan,sohar,sohib,soket,solar,solid,solok,sonar,sonde,sopan,sopir,sorak,sorot,sosok,sosor,sowan,spasi,spion,spons,spora,sport,start,stasi,stela,steno,stepa,stres,strip,strok,studi,suaka,suami,suang,suara,suari,suatu,subak,subam,subuh,subur,sudah,sudan,sudut,sugar,sugih,suite,sujud,sukar,sukma,sukuk,sukun,sulam,sulap,sulbi,sulit,sumah,sumba,sumbu,sumur,sunah,sunan,sunat,sunda,sunyi,super,supit,surah,suram,surat,surau,surga,suruh,surut,surya,susah,susuk,susul,susun,susut,sutan,sutra,suwir,swike,syair,syekh,syiar,syura,taala,tabah,tabat,tabel,tabib,tabik,tabir,tabuh,tabur,tadah,tafia,tagih,tahan,tahap,tahar,tahir,tahun,tajak,tajam,tajin,tajir,tajuk,takar,taksi,taksu,takut,takwa,talak,talam,talas,talun,talut,tamam,taman,tamat,tambi,tambo,tamil,tampo,tanah,tanak,tanam,tanda,tandu,tango,tania,tanpa,tante,tanur,tanya,taoco,taoge,tapai,tapak,tapal,tapin,tapis,taraf,tarif,tarik,tarot,taruh,tasik,tatah,tatak,tatal,tatap,tawaf,tawar,tawon,tawur,tayub,tebak,tebal,tebar,tebas,tebet,tebus,teduh,tegak,tegal,tegar,tegas,tegel,teguh,teguk,tegur,tekad,tekan,tekat,tekel,teken,tekor,tekuk,tekun,telah,telak,telan,telap,telat,telor,teluk,telur,teman,tempa,tempe,tempo,tenar,tenda,tenis,tenor,tensi,tentu,tenun,teori,tepat,tepis,tepok,tepuk,tepus,teras,terik,terka,teror,terpa,terus,tesis,tesla,tetap,tetas,teteh,tetes,tetra,tetua,teuku,tewas,tiada,tiang,tiara,tiban,tidak,tidur,tifus,tikar,tiket,tikus,tilas,timah,timpa,timun,timur,tiner,tinja,tinju,tinta,tipar,tipis,tirai,tiram,tiran,tiris,tirta,tirus,titan,titel,titen,titik,titip,titis,tiwul,tobat,togok,tokek,token,tokoh,tolak,tolan,tolok,toman,tomas,tomat,tonga,topan,topik,torsi,total,totok,totol,towel,trafo,trans,trofi,tuang,tuban,tubin,tubir,tubuh,tuduh,tugas,tuhan,tujuh,tukar,tukas,tukik,tukul,tular,tulat,tulen,tulip,tulis,tulus,tumis,tumit,tumor,tunai,tunas,tunda,tunik,tupai,turap,turis,turki,turun,turut,tusuk,tutor,tutul,tutup,tutur,tutut,tuyul,uapan,ubung,udang,udara,udeng,ujang,ujian,ujung,ukhti,ulama,ulang,ulkus,ulser,ultah,ulung,umami,umara,umbar,umbul,umpan,umpet,umrah,undak,unduh,undur,union,unjuk,unsur,untai,untuk,untun,unyil,upaya,upeti,urban,urgen,urine,urung,usaha,usang,using,uskup,ustaz,usung,utama,utang,utara,uwete,vadem,vakum,valas,valid,vasal,vegan,venus,versi,vespa,veste,video,vinil,viola,viral,virga,virgo,virus,vista,visum,vital,vodka,vokal,volan,vonis,wabah,wadah,wadak,waduh,waduk,wafat,wafer,wagyu,wahai,wahid,wahyu,wajah,wajan,wajar,wajib,wajik,wakaf,wakil,waktu,walau,walet,wanam,wanda,wangi,waras,warga,waria,waris,warna,warta,wasit,watak,water,welas,wesel,wetan,weton,wilis,windu,wisma,witir,wiwik,wujud,wukuf,wulan,wushu,xenia,yacht,yahya,yaitu,yakin,yakni,yakub,yasti,yatim,yesus,yogia,yolks,yonif,yonko,yudas,yunda,yunus,yuris,yusuf,zabur,zahid,zakat,zalim,zaman,zapin,zebra,zikir,zirah,zombi,zuhud,zuhur,zumba


================================================
FILE: .vscode/settings.json
================================================
{
  "prettier.singleQuote": false,
  "prettier.arrowParens": "always"
}


================================================
FILE: README.md
================================================
# Katla

Permainan kata harian. ~~Imitasi~~ Terinspirasi dari [Wordle](https://www.powerlanguage.co.uk/wordle/)

[![Powered by Vercel](https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg)](https://vercel.com?utm_source=katla&utm_campaign=oss)


================================================
FILE: components/Alert.tsx
================================================
import toast, { Toaster } from "react-hot-toast";

export default function Alert() {
  return (
    <Toaster
      position="top-center"
      gutter={8}
      toastOptions={Alert.options}
      containerStyle={{ top: 180 }}
    />
  );
}

Alert.options = {
  duration: 750,
  className:
    "bg-gray-900 text-white dark:bg-white dark:text-black text-center font-semibold py-2 px-3 rounded-sm",
};

interface AlertOptions {
  duration?: number;
  cb?: () => void;
  id: string;
}

Alert.show = (message: string, options: AlertOptions) => {
  const duration = options.duration || Alert.options.duration;

  let formatted: any = message;
  if (message.includes("\n")) {
    const lines = message.split("\n");
    formatted = (
      <div>
        {lines.map((line, i) => {
          if (i === lines.length) {
            return line;
          }

          return (
            <>
              {line}
              <br />
            </>
          );
        })}
      </div>
    );
  }

  toast(formatted, {
    id: options.id,
    duration,
  });

  if (options.cb) {
    setTimeout(() => {
      options.cb();
    }, duration);
  }
};


================================================
FILE: components/App.tsx
================================================
import { useEffect, useRef, useState } from "react";

import Board from "./Board";
import Keyboard from "./Keyboard";

import { GameStats } from "../utils/types";
import { decode } from "../utils/codec";
import { handleGameComplete, getFailureMessage } from "../utils/message";
import {
  checkHardModeAnswer,
  getAnswerStates,
  verifyStreak,
} from "../utils/game";
import { Game } from "../utils/types";
import { trackEvent } from "../utils/tracking";
import Alert from "./Alert";

interface Props {
  game: Game;
  stats: GameStats;
  words: string[];
  setStats: (stats: GameStats) => void;
  showStats: () => void;
  // optional event handlers for different game modes
  onSubmit?: (game: Game, userAnswer: string) => void;
  onComplete?: typeof handleGameComplete;
}

export default function App(props: Props) {
  const { game, stats, setStats, showStats, words } = props;

  const [invalidAnswer, setInvalidAnswer] = useState(false);
  const isAnimating = useRef(null);

  const answer = decode(game.hash);

  function handlePressChar(char: string) {
    // ignore if already finished
    if (game.state.answers[game.state.attempt - 1] === answer) {
      return;
    }

    if (isAnimating.current) {
      return;
    }

    if (!game.state.enableFreeEdit && char === "_") {
      return;
    }

    game.setState({
      ...game.state,
      answers: game.state.answers.map((answer, i) => {
        if (i === game.state.attempt) {
          if (answer.length === 5) {
            if (answer.includes("_")) {
              return answer.replace("_", char.toLowerCase());
            }

            // do nothing
            return answer;
          }

          return answer + char.toLowerCase();
        }

        return answer;
      }),
    });
  }

  function handleBackspace() {
    if (isAnimating.current) {
      return;
    }

    game.setState({
      ...game.state,
      answers: game.state.answers.map((answer, i) => {
        if (i === game.state.attempt) {
          return answer.slice(0, -1);
        }

        return answer;
      }),
    });
  }

  function handleSubmit() {
    if (isAnimating.current) {
      return;
    }

    // ignore submission user already know the answer
    if (
      // already fail
      game.state.attempt === 6 ||
      // already found the answer
      game.state.answers[game.state.attempt - 1] === answer
    ) {
      return;
    }

    const userAnswer = game.state.answers[game.state.attempt]
      .split("")
      .filter((char) => char !== "_")
      .join("");

    if (userAnswer.length < 5) {
      markInvalid();
      Alert.show("Tidak cukup huruf", { id: "answer" });
      return;
    }

    if (!words.includes(userAnswer.toLowerCase())) {
      markInvalid();
      Alert.show("Tidak ada dalam KBBI", { id: "answer" });
      game.trackInvalidWord(userAnswer);
      return;
    }

    if (game.state.enableHardMode && game.state.attempt > 0) {
      const [isInvalid, unusedChar, leterIndex] = checkHardModeAnswer(
        game.state,
        answer
      );
      if (isInvalid) {
        markInvalid();
        if (leterIndex) {
          Alert.show(`Huruf ke-${leterIndex} harus ${unusedChar}`, {
            id: "answer",
          });
        } else {
          Alert.show(`Huruf ${unusedChar} harus dipakai`, { id: "answer" });
        }
        return;
      }
    }

    props.onSubmit?.(game, userAnswer);

    setInvalidAnswer(false);
    game.submitAnswer?.(userAnswer, game.state.attempt + 1);
    game.setState({
      ...game.state,
      answers: game.state.answers.map((answer, i) => {
        if (i === game.state.attempt) {
          return userAnswer;
        }

        return answer;
      }),
      attempt: game.state.attempt + 1,
      lastCompletedDate: game.state.lastCompletedDate,
    });

    isAnimating.current = true;
    setTimeout(() => {
      isAnimating.current = false;

      if (answer === userAnswer) {
        if (typeof game.submitAnswer === "function") {
          game.setState({
            ...game.state,
            answers: game.state.answers.map((answer, i) => {
              if (i === game.state.attempt) {
                return userAnswer;
              }

              return answer;
            }),
            attempt: game.state.attempt + 1,
            lastCompletedDate: new Date().getTime(),
          });

          return;
        }

        props.onComplete?.({
          hash: game.hash,
          attempt: game.state.attempt + 1,
          stats: stats,
          cb: showStats,
        });

        trackEvent("succeed", {
          hash: game.hash,
          attempt: game.state.attempt + 1,
        });
        const currentStreak = stats.currentStreak + 1;

        game.setState({
          ...game.state,
          answers: game.state.answers.map((answer, i) => {
            if (i === game.state.attempt) {
              return userAnswer;
            }

            return answer;
          }),
          attempt: game.state.attempt + 1,
          lastCompletedDate: new Date().getTime(),
        });

        setStats({
          distribution: {
            ...stats.distribution,
            [game.state.attempt + 1]:
              stats.distribution[game.state.attempt + 1] + 1,
          },
          currentStreak,
          maxStreak: Math.max(stats.maxStreak, currentStreak),
        });
      } else if (game.state.attempt === 5) {
        if (typeof game.submitAnswer === "function") {
          game.setState({
            ...game.state,
            answers: game.state.answers.map((answer, i) => {
              if (i === game.state.attempt) {
                return userAnswer;
              }

              return answer;
            }),
            attempt: game.state.attempt + 1,
            lastCompletedDate: new Date().getTime(),
          });

          return;
        }

        trackEvent("failed", { hash: game.hash });
        setStats({
          distribution: {
            ...stats.distribution,
            fail: stats.distribution.fail + 1,
          },
          currentStreak: 0,
          maxStreak: stats.maxStreak,
        });

        const failureMessage = getFailureMessage(
          stats,
          getAnswerStates(game.state.answers[game.state.attempt], answer)
        );
        Alert.show(`${failureMessage}. Jawaban: ${answer}`, {
          id: "finish",
          duration: 1250,
          cb: showStats,
        });
      }
    }, 400 * 6);
  }

  function markInvalid() {
    setInvalidAnswer(true);
    setTimeout(() => {
      setInvalidAnswer(false);
    }, 600);
  }

  // auto resize board game to fit screen
  useEffect(() => {
    if (!game.ready) {
      return;
    }

    function handleResize() {
      const katla = document.querySelector("#katla") as HTMLDivElement;
      const footerHeight =
        document.querySelector("footer")?.getBoundingClientRect()?.height ?? 0;
      const maxTileHeight =
        window.innerHeight -
        document.querySelector("#header").getBoundingClientRect().height -
        document.querySelector("#keyboard").getBoundingClientRect().height -
        footerHeight;
      document.querySelector("#game-bar")?.getBoundingClientRect()?.height ?? 0;
      const maxTileSize = Math.min(maxTileHeight, window.innerWidth);
      const singleTileSize = Math.max(Math.floor((maxTileSize - 30) / 6), 62);

      const tileWidth = 5 * singleTileSize + 42;
      katla.style.height = maxTileSize + "px";
      katla.style.width = tileWidth + "px";
    }

    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, [game.ready]);

  return (
    <>
      <div className="mx-auto max-w-full px-4 flex justify-center items-center grow-0 shrink">
        <Board game={game} invalidAnswer={invalidAnswer} />
      </div>
      <Keyboard
        gameState={game.state}
        hash={game.hash}
        onPressChar={handlePressChar}
        onBackspace={handleBackspace}
        onSubmit={handleSubmit}
        isAnimating={isAnimating}
      />
    </>
  );
}


================================================
FILE: components/Board.tsx
================================================
import Tile from "./Tile";

import { Game, GameState } from "../utils/types";
import { decode } from "../utils/codec";
import { getAnswerStates } from "../utils/game";
import { FLIP_ANIMATION_DELAY_MS } from "../utils/constants";

interface Props {
  game: Game;
  invalidAnswer: boolean;
}

export default function Board(props: Props) {
  const { game, invalidAnswer } = props;
  const answer = decode(game.hash);

  function handlePress(row: number, index: number) {
    if (game.state.enableFreeEdit && row === game.state.attempt) {
      game.setState({
        ...game.state,
        answers: game.state.answers.map((answer, i) => {
          if (i === game.state.attempt) {
            return answer.slice(0, index) + "_" + answer.slice(index + 1);
          }

          return answer;
        }),
      });
    }
  }

  return (
    <div
      className="grid grid-rows-6 gap-1.5 max-w-full"
      style={{ aspectRatio: "1 / 1" }}
      id="katla"
    >
      {Array(6)
        .fill("")
        .map((_, i) => {
          let userAnswer = game.state.answers[i] ?? "";
          userAnswer += " ".repeat(5 - userAnswer.length);

          const answerStates = getAnswerStates(userAnswer, answer);
          const lieBoxes = game.state.lieBoxes?.[i];
          const isTheAnswer = answerStates.every((answer) => answer === "c");

          return (
            <div className="grid grid-cols-5 gap-1.5 relative" key={i}>
              {userAnswer.split("").map((char, index) => {
                let state = null;
                if (i < game.state.attempt) {
                  state = answerStates[index];
                  const [forcedColumn, forcedState] = lieBoxes ?? [];
                  if (
                    // only replace the box if liar mode is enabled
                    game.state.enableLiarMode &&
                    // and the column matches
                    forcedColumn === index &&
                    // and it's not the answer
                    !isTheAnswer
                  ) {
                    state = forcedState;
                  }
                }

                const isInvalid = invalidAnswer && i === game.state.attempt;

                return (
                  <Tile
                    key={`${index}-${char}`}
                    char={char}
                    state={state}
                    isInvalid={isInvalid}
                    delay={FLIP_ANIMATION_DELAY_MS * index}
                    onPress={() => handlePress(i, index)}
                  />
                );
              })}
            </div>
          );
        })}
    </div>
  );
}


================================================
FILE: components/Container.tsx
================================================
import { PropsWithChildren } from "react";

export default function Container(props: PropsWithChildren<{}>) {
  return (
    <div className="text-gray-800 dark:text-white text-center flex flex-col items-stretch overflow-y-hidden">
      {props.children}
    </div>
  );
}


================================================
FILE: components/EmojiRain.tsx
================================================
import { ComponentRef, useEffect, useRef, useState } from "react";

const CUSTOM_EVENT_NAME = "emoji-rain";

export default function EmojiRain() {
  const canvasRef = useRef<ComponentRef<"canvas">>(null);
  const [emoji, setEmoji] = useState(null);
  const timeoutRef = useRef<any>();

  useEffect(() => {
    if (!emoji) {
      return;
    }

    if (canvasRef.current === null) {
      return;
    }

    const w = window.innerWidth;
    const h = window.innerHeight;
    const ctx = canvasRef.current.getContext("2d");
    const bodyBackground = window.getComputedStyle(
      document.body
    ).backgroundColor;

    canvasRef.current.width = w;
    canvasRef.current.height = h;

    ctx.fillStyle = bodyBackground;
    ctx.fillRect(0, 0, w - 1, h - 1);

    const numOfEmojis = randomBetween(20, 30);
    let emojis: ElementPosition[] = Array(numOfEmojis)
      .fill(emoji)
      .map((emoji) => {
        let x = randomBetween(0, w - 1);
        let y = randomBetween(-42, -600);
        let size = randomBetween(32, 60);
        return new ElementPosition(emoji, x, y, size, size);
      });

    let frame = window.requestAnimationFrame(function draw() {
      const bodyBackground = window.getComputedStyle(
        document.body
      ).backgroundColor;
      emojis.forEach((element) => {
        element.updateY(
          interpolate(element.y, { min: -42, max: h }, { min: 8, max: 1 })
        );
      });

      emojis = emojis.filter((elem) => {
        if (elem.y > h + 42) {
          return false;
        }
        return true;
      });

      ctx.fillStyle = bodyBackground;
      ctx.fillRect(0, 0, w - 1, h - 1);

      emojis.forEach((elem) => {
        const alpha = interpolate(
          elem.y,
          { min: 0, max: (h / 3) * 2 },
          { min: 1, max: 0 }
        );
        ctx.fillStyle = `rgb(17, 24, 39, ${alpha})`;
        ctx.font = elem.width + "px sans";
        ctx.fillText(elem.emoji, elem.x, elem.y);
      });

      frame = window.requestAnimationFrame(draw);
    });
    return () => {
      ctx.fillStyle = bodyBackground;
      ctx.fillRect(0, 0, w - 1, h - 1);
      window.cancelAnimationFrame(frame);
    };
  }, [emoji]);

  useEffect(() => {
    function handleEmoji(e: CustomEvent) {
      setEmoji(e.detail);
      clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        setEmoji(null);
      }, 5000);
    }

    window.addEventListener(CUSTOM_EVENT_NAME, handleEmoji);
    return () => window.removeEventListener(CUSTOM_EVENT_NAME, handleEmoji);
  }, []);

  return <canvas ref={canvasRef} className="fixed pointer-events-none z-0" />;
}

export function rainEmoji(emoji: string) {
  const event = new CustomEvent(CUSTOM_EVENT_NAME, { detail: emoji });
  window.dispatchEvent(event);
}

interface RangeValue {
  min: number;
  max: number;
}

function interpolate(current: number, from: RangeValue, to: RangeValue) {
  if (current < from.min) {
    return to.min;
  }
  if (current > from.max) {
    return to.max;
  }
  // y-y1/y2-y1 = x-x1/x2-x1
  // y = x-x1/x2-x1*y2-y1 + y1
  return (
    ((current - from.min) / (from.max - from.min)) * (to.max - to.min) + to.min
  );
}

function randomBetween(min: number, max: number) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

class ElementPosition {
  emoji: string;
  x: number;
  y: number;
  width: number;
  height: number;
  velocity: number;

  constructor(
    emoji: string,
    x: number,
    y: number,
    width: number,
    height: number
  ) {
    this.emoji = emoji;
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
  }

  updateY(velocity: number) {
    this.y = this.y + velocity;
  }
}


================================================
FILE: components/EmojiSelector.tsx
================================================
import { Menu, MenuButton, MenuList, MenuItem } from "@reach/menu-button";
import { memo } from "react";

interface Props {
  onSendEmoji: (emoji: string) => void;
}

function EmojiSelector(props: Props) {
  return (
    <Menu>
      <MenuButton>
        <svg
          viewBox="0 0 24 24"
          width={22}
          height={22}
          stroke="currentColor"
          strokeWidth="2"
          fill="none"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <circle cx="12" cy="12" r="10"></circle>
          <path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
          <line x1="9" y1="9" x2="9.01" y2="9"></line>
          <line x1="15" y1="9" x2="15.01" y2="9"></line>
        </svg>
      </MenuButton>
      <EmojiBar onSelect={props.onSendEmoji} />
    </Menu>
  );
}

export default memo(EmojiSelector);

function EmojiBar({ onSelect }) {
  const emojis = ["🎉", "🥳", "😂", "😭", "❤️", "🤬"];

  return (
    <MenuList className="rounded-full bg-gray-100 border-gray-400 border dark:bg-gray-800 px-2 absolute top-2 z-10 slide-down">
      {emojis.map((emoji) => (
        <MenuItem
          key={emoji}
          className="p-2 text-xl select-none focus:outline-none transform transition-transform hover:scale-150 focus:scale-150"
          onSelect={() => onSelect(emoji)}
        >
          {emoji}
        </MenuItem>
      ))}
    </MenuList>
  );
}


================================================
FILE: components/Header.tsx
================================================
import Head from "next/head";
import dynamic from "next/dynamic";
import React, { ReactNode } from "react";

const EmojiSelector = dynamic(() => import("./EmojiSelector"), { ssr: false });

interface Props {
  title?: string;
  description?: string;
  keywords?: string[];
  ogImage?: string;
  customHeading?: ReactNode;
  warnStorageDisabled?: boolean;
  isLiveMode?: boolean;
  themeColor?: string;
  showLiarOption?: boolean;
  onShowStats?: () => void;
  onShowHelp?: () => void;
  onShowSettings?: () => void;
  onSendEmoji?: (emoji: string) => void;
  path?: string;
}

export default function Header(props: Props) {
  const {
    title = "Katla - Permainan Tebak Kata | 1 Hari 1 Kata 6 Kesempatan",
    description = "Tebak kata rahasia dalam 6 percobaan. Kata baru tersedia setiap hari.",
    keywords = [
      "game",
      "permainan",
      "main",
      "tebak",
      "kata",
      "rahasia",
      "sembunyi",
      "clue",
      "petunjuk",
      "wordle",
      "bahasa",
      "indonesia",
      "karya",
      "anak",
      "bangsa",
      "kbbi",
    ],
    ogImage = "https://katla.vercel.app/og.png",
    customHeading,
    onShowStats,
    onShowHelp,
    onShowSettings,
    onSendEmoji,
    warnStorageDisabled,
    isLiveMode,
    themeColor = "#15803D",
    showLiarOption,
    path = "/",
  } = props;

  return (
    <header className="px-4 mx-auto max-w-lg w-full pt-2 pb-4" id="header">
      <Head>
        <title>{title}</title>
        <meta name="description" content={description} />
        <meta name="keywords" content={keywords.join(", ")} />
        <meta property="og:url" content="https://katla.vercel.app/" />
        <meta property="og:type" content="website" />
        <meta property="og:title" content={title} />
        <meta property="og:description" content={description} />
        <meta property="og:keywords" content={keywords.join(", ")} />
        <meta property="og:image" content={ogImage} />
        <link rel="canonical" href={"https://katla.id" + path} />

        <meta name="twitter:card" content="summary_large_image" />
        <meta property="twitter:domain" content="katla.vercel.app" />

        <meta name="theme-color" content={themeColor} />
        <link href="/katla-32x32.png" rel="icon shortcut" sizes="3232" />
        <link href="/katla-192x192.png" rel="apple-touch-icon" />
      </Head>
      {isLiveMode && (
        <div className="text-xs mb-2 text-yellow-800 dark:text-yellow-200">
          Mode lawan masih dalam tahap uji coba.
        </div>
      )}
      {showLiarOption && (
        <div className="text-xs mb-2">
          Kurang menantang? Gunakan{" "}
          <button onClick={onShowSettings} className="color-accent">
            mode bohong
          </button>
        </div>
      )}
      {warnStorageDisabled && (
        <div className="text-xs mb-2 text-yellow-800 dark:text-yellow-200">
          Browser yang kamu gunakan saat ini tidak dapat menyimpan progres
          permainan seperti jawaban sementara dan statistik. Silahkan gunakan
          browser lain untuk pengalaman yang lebih optimal.
        </div>
      )}
      <div className="border-b border-b-gray-500  relative text-gray-500">
        <h1
          className="uppercase text-4xl dark:text-gray-200 text-gray-900 font-bold w-max mx-auto relative z-10 mb-2"
          style={{ letterSpacing: 4 }}
        >
          {customHeading ?? "Katla"}
        </h1>
        <div className="absolute flex flex-row items-center justify-between inset-0">
          <div className="flex space-x-2">
            <button
              onClick={onShowHelp}
              title="Bantuan"
              aria-label="Pengaturan"
              style={{
                visibility: onShowHelp ? "visible" : "hidden",
                height: 24,
              }}
              tabIndex={-1}
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                height="24"
                viewBox="0 0 24 24"
                width="24"
              >
                <path
                  fill="currentColor"
                  d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"
                ></path>
              </svg>
            </button>
            <div className="relative flex">
              {onSendEmoji && <EmojiSelector onSendEmoji={onSendEmoji} />}
            </div>
          </div>
          <div className="flex gap-2">
            <button
              onClick={onShowStats}
              title="Statistik"
              aria-label="Statistik"
              style={{ visibility: onShowStats ? "visible" : "hidden" }}
              tabIndex={-1}
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                height="24"
                viewBox="0 0 24 24"
                width="24"
              >
                <path
                  fill="currentColor"
                  d="M16,11V3H8v6H2v12h20V11H16z M10,5h4v14h-4V5z M4,11h4v8H4V11z M20,19h-4v-6h4V19z"
                ></path>
              </svg>
            </button>
            <button
              onClick={onShowSettings}
              title="Pengaturan"
              aria-label="Pengaturan"
              style={{ visibility: onShowSettings ? "visible" : "hidden" }}
              tabIndex={-1}
            >
              <svg
                viewBox="0 0 24 24"
                width="24"
                height="24"
                stroke="currentColor"
                strokeWidth="2"
                fill="none"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <circle cx="12" cy="12" r="10"></circle>
                <line x1="12" y1="8" x2="12" y2="12"></line>
                <line x1="12" y1="16" x2="12.01" y2="16"></line>
              </svg>
            </button>
          </div>
        </div>
      </div>
    </header>
  );
}


================================================
FILE: components/HeadingWithNum.tsx
================================================
interface Props {
  num: string | number | null;
  enableLiarMode?: boolean;
}

export default function HeadingWithNum(props: Props) {
  const [_, mm, dd] = new Date().toISOString().split("T")[0].split("-");
  const isIndonesiaIndependenceDay = mm === "08" && dd === "17";
  let customNumClass = "";
  if (isIndonesiaIndependenceDay) {
    customNumClass = "text-white bg-red-500 p-1";
  }

  return (
    <span>
      {props.enableLiarMode ? "Katlie" : <><span style={{ color: "#EE2A35"}}>K</span>at<span style={{ color: "#009736"}}>la</span></>}
      {props.num && (
        <sup
          className={`-top-4 tracking-tight ${customNumClass}`}
          style={{ fontSize: "45%" }}
        >
          #{props.num}
        </sup>
      )}
    </span>
  );
}


================================================
FILE: components/HelpModal.tsx
================================================
import { memo } from "react";
import Modal from "./Modal";
import Tile from "./Tile";

interface Props {
  isOpen: boolean;
  onClose: () => void;
  isLiveMode?: boolean;
}

export default memo(HelpModal);

function HelpModal(props: Props) {
  const { isOpen, onClose, isLiveMode = false } = props;
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Title>Cara Bermain</Modal.Title>
      <div className="text-sm">
        <p className="mb-2">
          Tebak <strong className="uppercase">Katla</strong> dalam 6 kesempatan.
          {isLiveMode ? (
            <p className="my-2">
              Mainkan bersama teman-temanmu (maksimum 10 orang). Setelah
              permainan selesai, babak baru akan otomatis dimulai dalam 5 detik.
            </p>
          ) : (
            <span>1 hari ada 1 kata rahasia.</span>
          )}
        </p>
        <p className="mb-2">
          Setiap tebakan harus merupakan kata valid 5 huruf sesuai KBBI. Tekan
          tombol ENTER untuk mengirimkan jawaban
        </p>
        <p className="mb-2">
          Setelah jawaban dikirimkan, warna kotak akan berubah untuk menunjukkan
          seberapa dekat tebakanmu dari kata rahasia
        </p>
        <hr className="dark:border-gray-700 border-gray-500 mb-4" />
        <strong className="text-lg mb-4 block">Contoh</strong>
        <div
          className="grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2"
          style={{ aspectRatio: "6 / 1" }}
        >
          {"semua".split("").map((char, i) => {
            return (
              <Tile
                key={i}
                char={char}
                state={char === "s" ? "c" : null}
                delay={0}
              />
            );
          })}
        </div>
        <div className="mb-4">
          Huruf <strong>S</strong> ada dan posisinya sudah tepat
        </div>
        <div
          className="grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2"
          style={{ aspectRatio: "6 / 1" }}
        >
          {"kasur".split("").map((char, i) => {
            return (
              <Tile
                key={i}
                char={char}
                state={char === "a" ? "e" : null}
                delay={0}
              />
            );
          })}
        </div>
        <div className="mb-4">
          Huruf <strong>A</strong> ada namun posisinya belum tepat
        </div>
        <div
          className="grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2"
          style={{ aspectRatio: "6 / 1" }}
        >
          {"duduk".split("").map((char, i) => {
            return (
              <Tile
                key={i}
                char={char}
                state={char === "k" ? "w" : null}
                delay={0}
              />
            );
          })}
        </div>
        <div className="mb-4">
          Tidak ada huruf <strong>K</strong> di kata rahasia
        </div>
        <hr className="dark:border-gray-700 border-gray-500 mb-4" />
        {isLiveMode ? null : (
          <p className="font-semibold">
            Akan ada <strong className="uppercase">Katla</strong> baru setiap
            hari!
          </p>
        )}
      </div>
    </Modal>
  );
}


================================================
FILE: components/Keyboard.tsx
================================================
import { MutableRefObject, useEffect, useRef } from "react";

import KeyboardButton from "./KeyboardButton";
import { AnswerState, GameState } from "../utils/types";
import { decode } from "../utils/codec";

interface Props {
  onPressChar: (char: string) => void;
  onBackspace: () => void;
  onSubmit: () => void;
  gameState: GameState;
  hash: string;
  isAnimating: MutableRefObject<boolean>;
}

export default function Keyboard(props: Props) {
  const { onPressChar, onBackspace, onSubmit, gameState, hash, isAnimating } =
    props;
  const answer = decode(hash);
  const usedChars = new Set(
    gameState.answers
      .slice(0, gameState.attempt)
      .map((answer) => answer.split(""))
      .flat()
  );

  const correctChars = new Set();
  gameState.answers.forEach((userAnswer, i) => {
    if (i < gameState.attempt) {
      userAnswer.split("").forEach((char, j) => {
        if (answer[j] === char) {
          correctChars.add(char);
        }
      });
    }
  });

  function getKeyboardState(char: string): AnswerState {
    let state = null;
    if (correctChars.has(char)) {
      state = "c";
    } else if (usedChars.has(char) && answer.includes(char)) {
      state = "e";
    } else if (usedChars.has(char)) {
      state = "w";
    }

    return gameState.enableLiarMode ? null : state;
  }

  const pressed = useRef(null);
  useEffect(() => {
    function handleKeydown(e: KeyboardEvent) {
      if (gameState.attempt === 6) {
        return;
      }

      const currentText = gameState.answers[gameState.attempt];
      if (
        pressed.current === true &&
        e.key === currentText[currentText.length - 1]
      ) {
        return;
      }

      if (isAnimating.current) {
        return;
      }

      pressed.current = true;
      if (e.key === "Backspace") {
        onBackspace();
      } else if (e.key === "Enter") {
        // prevent modal to be opened when pressing enter
        e.preventDefault();
        onSubmit();
      } else if (gameState.enableFreeEdit && e.key === "_") {
        onPressChar(e.key);
      } else if (/[a-z]/i.test(e.key) && e.key.length === 1) {
        onPressChar(e.key);
      }
    }

    function handleKeyup() {
      pressed.current = false;
    }

    document.addEventListener("keydown", handleKeydown);
    document.addEventListener("keyup", handleKeyup);
    return () => {
      document.removeEventListener("keydown", handleKeydown);
      document.removeEventListener("keyup", handleKeyup);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [gameState]);

  return (
    <div
      className="max-w-lg w-full mx-auto space-y-3 flex flex-col p-4 relative z-auto"
      id="keyboard"
    >
      <div className="flex space-x-2">
        {"qwertyuiop".split("").map((char) => (
          <KeyboardButton
            key={char}
            state={getKeyboardState(char)}
            onClick={() => onPressChar(char)}
          >
            {char}
          </KeyboardButton>
        ))}
      </div>
      <div className="flex space-x-2">
        <div style={{ flex: 0.5 }}></div>
        {"asdfghjkl".split("").map((char) => (
          <KeyboardButton
            key={char}
            state={getKeyboardState(char)}
            onClick={() => onPressChar(char)}
          >
            {char}
          </KeyboardButton>
        ))}
        <div style={{ flex: 0.5 }}></div>
      </div>
      <div className="flex space-x-2">
        <KeyboardButton state={null} onClick={onSubmit} scale={1.5}>
          Enter
        </KeyboardButton>
        {"zxcvbnm".split("").map((char) => (
          <KeyboardButton
            key={char}
            state={getKeyboardState(char)}
            onClick={() => onPressChar(char)}
          >
            {char}
          </KeyboardButton>
        ))}
        {gameState.enableFreeEdit && (
          <KeyboardButton
            state={null}
            onClick={() => onPressChar("_")}
            scale={1.5}
          >
            _
          </KeyboardButton>
        )}
        <KeyboardButton state={null} onClick={onBackspace} scale={1.5}>
          <svg
            xmlns="http://www.w3.org/2000/svg"
            height="24"
            viewBox="0 0 24 24"
            width="24"
          >
            <path
              fill="currentColor"
              d="M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.9.89 1.59.89h15c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H7.07L2.4 12l4.66-7H22v14zm-11.59-2L14 13.41 17.59 17 19 15.59 15.41 12 19 8.41 17.59 7 14 10.59 10.41 7 9 8.41 12.59 12 9 15.59z"
            ></path>
          </svg>
        </KeyboardButton>
      </div>
    </div>
  );
}


================================================
FILE: components/KeyboardButton.tsx
================================================
import { ComponentProps, memo } from "react";
import { AnswerState } from "../utils/types";

type Props = {
  state: AnswerState;
  scale?: number;
} & Omit<ComponentProps<"button">, "className" | "style">;

export default function KeyboardButton(props: Props) {
  let color = "bg-gray-300 text-gray-900 dark:bg-gray-500 dark:text-gray-200";
  switch (props.state) {
    case "c":
      color = "text-white bg-correct";
      break;
    case "e":
      color = "text-white bg-exist";
      break;
    case "w":
      color = "text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700";
      break;
    default:
  }

  return (
    <button
      className={`rounded-md uppercase font-semibold text-sm flex items-center justify-center ${color} select-none`}
      style={{ minHeight: 48, flex: props.scale ?? 1 }}
      {...props}
    />
  );
}


================================================
FILE: components/Link.tsx
================================================
import NextLink from "next/link";
import { ComponentProps } from "react";

export default function Link(props: ComponentProps<typeof NextLink>) {
  const { children, ...rest } = props;
  return (
    <NextLink {...rest}>
      <a className="color-accent">{children}</a>
    </NextLink>
  );
}


================================================
FILE: components/LiveStatsModal.tsx
================================================
import Modal from "./Modal";
import Alert from "./Alert";
import { useMap, useOthers, useSelf } from "@liveblocks/react";

interface Props {
  isOpen: boolean;
  onClose: () => void;
  totalPlay: number;
}

const GRAPH_WIDTH_MIN_RATIO = 10;

export default function LiveStatsModal(props: Props) {
  const others = useOthers();
  const self = useSelf();
  const { isOpen, onClose, totalPlay } = props;

  const users = others
    .toArray()
    .concat(self)
    .filter(Boolean)
    .sort((a, b) => {
      return (a.presence?.winCount ?? 0) > (b.presence?.winCount ?? 0) ? -1 : 1;
    });

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Title>Statistik</Modal.Title>
      <div className="w-10/12 mx-auto mb-8">
        <h3 className="uppercase font-semibold mb-4">Distribusi Kemenangan</h3>
        <div>
          {users.map((user) => {
            const shouldHighlight = user.connectionId === self.connectionId;
            const winCount = user.presence?.winCount ?? 0;
            const ratio =
              totalPlay === 0
                ? GRAPH_WIDTH_MIN_RATIO
                : Math.max((winCount / totalPlay) * 100, GRAPH_WIDTH_MIN_RATIO);
            const alignment =
              ratio === GRAPH_WIDTH_MIN_RATIO
                ? "justify-center"
                : "justify-end";
            const background = shouldHighlight ? "bg-accent" : "bg-gray-500";
            return (
              <div
                className="grid grid-cols-leaderboard h-5 mb-2"
                key={user.connectionId}
              >
                <div className="tabular-nums">{user.id}</div>
                <div className="w-full h-full pl-1">
                  <div
                    className={`text-right text-white ${background} flex ${alignment} px-2 font-bold`}
                    style={{ width: ratio + "%" }}
                  >
                    {winCount}
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </Modal>
  );
}


================================================
FILE: components/Modal.tsx
================================================
import { DialogOverlay, DialogContent } from "@reach/dialog";
import { useEffect, useState, ReactNode, useCallback } from "react";

import { getTotalPlay, isGameFinished } from "../utils/game";
import { GameStats, Game } from "../utils/types";

interface Props {
  isOpen: boolean;
  onClose?: () => void;
  children: ReactNode;
}

export default function Modal(props: Props) {
  const { isOpen, onClose, children } = props;
  return (
    <DialogOverlay
      isOpen={isOpen}
      onDismiss={onClose}
      className="fixed inset-0 bg-black bg-opacity-50 overflow-y-auto z-10"
    >
      <DialogContent aria-labelledby="dialogTitle">
        <div className="dark:bg-gray-900 bg-white dark:text-gray-200 text-gray-900 w-5/6 max-w-lg absolute top-12 md:top-16 left-6 right-6 mx-auto p-4">
          <button
            onClick={onClose}
            title="close"
            aria-label="close"
            className="absolute right-4 top-4 text-gray-500"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              height="24"
              viewBox="0 0 24 24"
              width="24"
            >
              <path
                fill="currentColor"
                d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
              ></path>
            </svg>
          </button>
          {children}
        </div>
      </DialogContent>
    </DialogOverlay>
  );
}

const Title = ({ children }) => (
  <h2 id="dialogTitle" className="text-center uppercase font-semibold my-4">
    {children}
  </h2>
);
Modal.Title = Title;

type ModalState = "help" | "stats" | "settings";

type ModalStateReturn = [ModalState, (state: ModalState) => void, () => void];

export function useModalState(game: Game, stats: GameStats): ModalStateReturn {
  const [modalState, setModalState] = useState<ModalState | null>(null);

  useEffect(() => {
    if (!game.ready) {
      return;
    }

    // show help screen for first-time player
    if (
      getTotalPlay(stats) === 0 &&
      game.state.attempt === 0 &&
      game.state.answers[0] === ""
    ) {
      setModalState("help");
    }
    // show stats screen if user already finished playing current session
    else if (isGameFinished(game)) {
      setModalState("stats");
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [game.ready]);

  useEffect(() => {
    // reset modal every new game
    setModalState(null);
  }, [game.num]);

  const resetModalState = useCallback(() => {
    setModalState(null);
  }, []);

  return [modalState, setModalState, resetModalState];
}


================================================
FILE: components/SettingsModal.tsx
================================================
import { useTheme } from "next-themes";

import Link from "./Link";
import Modal from "./Modal";
import Alert from "./Alert";

import {
  GAME_STATE_KEY,
  GAME_STATS_KEY,
  INVALID_WORDS_KEY,
  LAST_HASH_KEY,
  LAST_SESSION_RESET_KEY,
} from "../utils/constants";
import LocalStorage from "../utils/browser";
import { ForcedResult, Game, LiveConfig } from "../utils/types";
import { shareInviteLink } from "../utils/liveGame";

interface Props {
  isOpen: boolean;
  onClose: () => void;
  game: Game;
  liveConfig?: LiveConfig;
}

export default function SettingsModal(props: Props) {
  const { game, isOpen, onClose, liveConfig } = props;
  const { resolvedTheme, setTheme } = useTheme();
  const showLiarMode = game.num === 71;
  const correctColor = game.state.enableHighContrast ? "biru" : "hijau";
  const incorrectColor = game.state.enableHighContrast ? "oranye" : "kuning";

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Title>Pengaturan</Modal.Title>
      {game.num !== -1 && (
        <Switch
          title="Mode Sulit"
          subtitle="Semua petunjuk dari jawaban sebelumnya harus digunakan"
          onlyEnableOnFirstAttempt
          attempt={game.state.attempt}
          active={game.state.enableHardMode}
          onChange={(enableHardMode) => {
            game.setState({ ...game.state, enableHardMode });
          }}
        />
      )}
      <Switch
        title="Mode Gelap"
        active={resolvedTheme === "dark"}
        onChange={(active) => {
          setTheme(active ? "dark" : "light");
        }}
      />
      <Switch
        title="Mode Buta Warna"
        subtitle="Warna kontras tinggi"
        active={game.state.enableHighContrast}
        onChange={(enableHighContrast) => {
          game.setState({ ...game.state, enableHighContrast });
        }}
      />
      <Switch
        title="Mode Edit Bebas"
        subtitle="Hapus huruf di kotak manapun dan lewati kotak dengan karakter '_'"
        isExperimental
        active={game.state.enableFreeEdit}
        onChange={(enableFreeEdit) => {
          game.setState({ ...game.state, enableFreeEdit });
        }}
      />
      {showLiarMode && (
        <Switch
          title="Mode Bohong"
          subtitle={`Setiap baris terdapat 1 kotak acak yang belum tentu mencerminkan petunjuk seharusnya (misal ${correctColor} menjadi ${incorrectColor})`}
          onlyEnableOnFirstAttempt
          attempt={game.state.attempt}
          active={game.state.enableLiarMode}
          onChange={(enableLiarMode) => {
            game.setState({
              ...game.state,
              enableLiarMode,
              lieBoxes: generateLieBoxes(),
            });
          }}
        />
      )}
      {liveConfig ? (
        liveConfig.isHost ? (
          <AdminTools config={liveConfig} game={game} onClose={onClose} />
        ) : (
          <PlayerTools config={liveConfig} />
        )
      ) : (
        <AdditionalInformation />
      )}
    </Modal>
  );
}

interface AdminToolsProps {
  game: Game;
  onClose: () => void;
  config: LiveConfig;
}

function AdminTools(props: AdminToolsProps) {
  const { game, onClose, config } = props;

  function handleReset() {
    game.resetState();
    onClose();
  }

  function handleInvite() {
    shareInviteLink(config, onClose);
  }

  return (
    <div>
      <h4 className="text-center uppercase font-semibold my-4">Mode Lawan</h4>
      <button className="color-accent block mb-2" onClick={handleInvite}>
        Ajak pemain
      </button>
      <button className="color-accent block mb-2" onClick={handleReset}>
        Mulai dari awal
      </button>
    </div>
  );
}

interface PlayerToolsProps {
  config: LiveConfig;
}

function PlayerTools(props: PlayerToolsProps) {
  const { config } = props;

  function handleInvite() {
    shareInviteLink(config);
  }

  return (
    <div>
      <h4 className="text-center uppercase font-semibold my-4">
        Pengaturan Mode Lawan
      </h4>
      <button className="color-accent" onClick={handleInvite}>
        Ajak pemain
      </button>
    </div>
  );
}

function AdditionalInformation() {
  function handleReset() {
    LocalStorage.removeItem(GAME_STATE_KEY);
    LocalStorage.removeItem(GAME_STATS_KEY);
    LocalStorage.removeItem(INVALID_WORDS_KEY);
    LocalStorage.removeItem(LAST_HASH_KEY);
    LocalStorage.setItem(
      LAST_SESSION_RESET_KEY,
      new Date().getTime().toString()
    );
    window.location.reload();
  }

  return (
    <>
      <h4 className="text-center uppercase font-semibold my-4">Informasi</h4>
      <p className="mb-4">
        <strong>Katla</strong> merupakan <s>imitasi</s> adaptasi dari{" "}
        <a
          href="https://www.powerlanguage.co.uk/wordle/"
          className="color-accent"
        >
          Wordle
        </a>
      </p>
      <p className="mb-4">
        Kamu bisa melihat daftar kata yang telah digunakan sebelumnya di dalam{" "}
        <Link href="/arsip">Arsip</Link>
      </p>
      <div>
        <h2 className="text-xl font-semibold">Terdapat Masalah?</h2>
        <Link href="/bantuan">Bantuan</Link>
        <span> atau </span>
        <button onClick={handleReset} className="color-accent">
          reset sesi sekarang
        </button>
      </div>
    </>
  );
}

interface SwitchProps {
  active: boolean;
  title: string;
  subtitle?: string;
  onlyEnableOnFirstAttempt?: boolean;
  attempt?: number;
  isExperimental?: boolean;
  onChange: (active: boolean) => void;
}

function Switch(props: SwitchProps) {
  const {
    title,
    subtitle,
    isExperimental,
    active,
    onChange,
    onlyEnableOnFirstAttempt,
    attempt,
  } = props;

  const disabled = onlyEnableOnFirstAttempt && attempt > 0;
  const disabledText = `${title} hanya dapat diganti di awal permainan`;
  const warningText = onlyEnableOnFirstAttempt
    ? `Hanya dapat diganti di awal permainan`
    : isExperimental
    ? "Masih dalam tahap uji coba"
    : "";

  function handleClick() {
    if (disabled) {
      Alert.show(disabledText, { id: "disabled" });
      return;
    }

    onChange(!active);
  }

  return (
    <div className="flex justify-between py-2 my-2 text-lg items-center border-b border-gray-200 dark:border-gray-700 space-x-2">
      <div className="flex flex-col">
        <p className="mb-0">
          {title}{" "}
          {warningText && (
            <span className="text-xs text-yellow-600 dark:text-yellow-400 inline-block">
              {"(" + warningText + ")"}
            </span>
          )}
        </p>
        {subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
      </div>
      <button
        className={`${
          active ? "bg-correct" : "bg-gray-500"
        } w-10 h-6 flex items-center rounded-full px-1 flex-shrink-0`}
        onClick={handleClick}
        style={{ cursor: disabled ? "not-allowed" : "pointer" }}
      >
        <div
          className={`bg-white w-4 h-4 rounded-full shadow-md transform transition ${
            active ? "translate-x-4" : ""
          }`}
        ></div>
      </button>
    </div>
  );
}

function generateLieBoxes(): ForcedResult[] {
  // only 5 because we want the last one to show the real answer
  return Array(5)
    .fill(0)
    .map(() => {
      const isExist = Math.random() > 0.35;
      const isCorrect = Math.random() > 0.5;
      const col = Math.floor(Math.random() * 5);
      return isExist ? (isCorrect ? [col, "c"] : [col, "e"]) : [col, "w"];
    });
}


================================================
FILE: components/SponsorshipFooter.tsx
================================================
export default function SponsorshipFooter() {
  return (
    <footer className="text-sm pb-4">
      Built with{" "}
      <a
        className="font-bold"
        href="https://vercel.com?utm_source=katla&utm_campaign=oss"
      >
        Vercel
      </a>
    </footer>
  );
}


================================================
FILE: components/StatsModal.tsx
================================================
import useSWR from "swr";
import { LegacyRef, useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";

import Modal from "./Modal";

import { AnswerState, Game, GameStats } from "../utils/types";
import { decode } from "../utils/codec";
import fetcher from "../utils/fetcher";
import { pad0 } from "../utils/formatter";
import {
  useRemainingTime,
  getTotalPlay,
  getTotalWin,
  getAnswerStates,
} from "../utils/game";
import { checkNativeShareSupport, shareText } from "../utils/browser";
import { isEidMessage } from "../utils/message";

interface Props {
  isOpen: boolean;
  onClose: () => void;
  game: Game;
  stats: GameStats;
  remainingTime?: ReturnType<typeof useRemainingTime>;
}

declare global {
  interface Window {
    adsbygoogle: { loaded: boolean; push: (v: unknown) => void };
  }
}

const GRAPH_WIDTH_MIN_RATIO = 10;

export default function StatsModal(props: Props) {
  const { isOpen, onClose, game, stats } = props;
  const { resolvedTheme } = useTheme();
  const isAnswered =
    game.state.answers[game.state.attempt - 1] === decode(game.hash);
  const [canShareImage, setCanShareImage] = useState(false);
  const [showAnswersCheckbox, setShowAnswersCheckbox] = useState(false);

  useEffect(() => {
    const canShareImage =
      checkNativeShareSupport() && typeof navigator.canShare === "function";
    setCanShareImage(canShareImage);

    if (!canShareImage) {
      // preload image share logic
      import("file-saver");
    }
  }, []);

  const answer = decode(game.hash);
  const showShare =
    game.state.attempt === 6 ||
    game.state.answers[game.state.attempt - 1] === answer;
  const totalWin = getTotalWin(stats);
  const totalPlay = getTotalPlay(stats);
  const title = game.state.enableLiarMode ? "Katlie" : "Katla";

  function generateText() {
    const hardModeMarker = game.state.enableHardMode ? "*" : "";
    const score =
      game.state.answers[game.state.attempt - 1] === answer
        ? game.state.attempt
        : "X";
    let text = `${title} ${game.num} ${score}/6${hardModeMarker}\n\n`;

    game.state.answers.filter(Boolean).forEach((userAnswer) => {
      const answerEmojis = getAnswerStates(userAnswer, answer).map((state) => {
        switch (state) {
          case "c":
            return game.state.enableHighContrast ? "🟧" : "🟩";
          case "e":
            return game.state.enableHighContrast ? "🟦" : "🟨";
          case "w":
            return resolvedTheme === "dark" ? "⬛" : "⬜️";
        }
      });
      text += `${answerEmojis.join("")}\n`;
    });

    if (
      game.num === 102 &&
      isEidMessage(game.state.answers[game.state.attempt - 1])
    ) {
      text = `🙏🙏🙏🙏🙏\n\n`;
      text += `⬛⬛🟩⬛⬛\n`;
      text += `⬛🟩🟨🟩⬛\n`;
      text += `🟩🟨🟩🟨🟩\n`;
      text += `⬛🟩🟨🟩⬛\n`;
      text += `⬛⬛🟩⬛⬛\n`;
      text += "\n" + window.location.href;
      return text;
    }

    text += "\n" + window.location.href;
    return text;
  }

  function handleShare() {
    shareText(generateText(), { cb: onClose });
  }

  async function handleShareImage() {
    const canvas = document.createElement("canvas");
    canvas.height = 1400;
    canvas.width = 900;

    const ctx = canvas.getContext("2d");
    ctx.fillStyle = resolvedTheme === "dark" ? "#111827" : "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    const gap = 10;
    const paddingH = 200;
    const paddingT = 400;
    const size = (canvas.width - paddingH * 2 - gap * 4) / 5;

    const score =
      game.state.answers[game.state.attempt - 1] === answer
        ? game.state.attempt
        : "X";
    const hardModeMarker = game.state.enableHardMode ? "*" : "";

    let answers = game.state.answers.slice(0, game.state.attempt);
    let answerStates: AnswerState[][] = answers.map((answer) => {
      return getAnswerStates(answer, decode(game.hash));
    });
    let text = `${title} ${game.num} ${score}/6${hardModeMarker}\n\n`;

    if (
      game.num === 102 &&
      isEidMessage(game.state.answers[game.state.attempt - 1])
    ) {
      text = `🙏🙏🙏🙏🙏🙏`;
      answers = ["mohon", "maaf", "lahir", "dan", "batin"];
      answerStates = [
        ["w", "w", "c", "w", "w"],
        ["w", "c", "e", "c", "w"],
        ["c", "e", "c", "e", "c"],
        ["w", "c", "e", "c", "w"],
        ["w", "w", "c", "w", "w"],
      ];
    }

    ctx.font = "bold 42px sans-serif";
    ctx.textAlign = "center";
    ctx.fillStyle = resolvedTheme === "dark" ? "#ffffff" : "#111827";
    ctx.fillText(text, canvas.width / 2, 300);
    ctx.font = "32px sans-serif";
    ctx.fillText("katla.vercel.app", canvas.width / 2, canvas.height - 150);

    answerStates.forEach((states, y) => {
      const answer = answers[y];
      states.forEach((state, x) => {
        if (state === "c") {
          ctx.fillStyle = game.state.enableHighContrast ? "#f5793a" : "#15803d";
        } else if (state === "e") {
          ctx.fillStyle = game.state.enableHighContrast ? "#85c0f9" : "#ca8a04";
        } else {
          ctx.fillStyle = resolvedTheme === "dark" ? "#374151" : "#6b7280";
        }

        const marginH = gap * x;
        const marginV = gap * y;
        const rectX = paddingH + x * size + marginH;
        const rectY = paddingT + y * size + marginV;

        ctx.beginPath();
        ctx.rect(rectX, rectY, size, size);
        ctx.fill();

        if (showAnswersCheckbox) {
          ctx.font = "bold 54px sans-serif";
          ctx.fillStyle = "#ffffff";
          ctx.fillText(
            answer[x]?.toUpperCase() ?? "",
            rectX + size / 2,
            rectY + size / 1.5
          );
        }
      });
    });

    const dataURL = canvas.toDataURL();
    const blob = await (await fetch(dataURL)).blob();

    if (canShareImage) {
      const imageName = showAnswersCheckbox
        ? `katla-${game.num}-with-answers.jpg`
        : `katla-${game.num}.jpg`;
      const shareData = {
        files: [
          new File([blob], imageName, {
            type: "image/jpeg",
            lastModified: new Date().getTime(),
          }),
        ],
      };
      navigator.share(shareData).catch(() => {});
    } else {
      const { saveAs } = await import("file-saver").then((mod) => mod.default);
      const imageName = showAnswersCheckbox
        ? `katla-${game.num}-with-answers`
        : `katla-${game.num}`;
      saveAs(blob, imageName);
    }
  }

  function handleShareToTwitter() {
    const text = generateText();
    const encodeURI = text.replace(/\n/g, "%0A");
    const shareToTwitter = `https://twitter.com/intent/tweet?text=${encodeURI}`;
    window.open(shareToTwitter, "_blank");
  }

  const { fail: _, ...distribution } = stats.distribution;
  const maxDistribution = Math.max(...Object.values(distribution));

  const [isAdUnitRendered, setIsAdUnitRendered] = useState(false);
  const adsByGooglePushedRef = useRef(false);
  const adUnitRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen || !isAdUnitRendered || adsByGooglePushedRef.current) return;

    const adUnit = adUnitRef.current;
    adUnit.innerHTML = `<!-- stats-ads -->
    <ins class="adsbygoogle"
      style="display:block"
      data-ad-client="ca-pub-3081263972680635"
      data-ad-slot="2576511153"
      data-ad-format="auto"
      data-full-width-responsive="true"></ins>`;

    try {
      // @ts-ignore
      window.adsbygoogle = (window.adsbygoogle || []).push({});
      adsByGooglePushedRef.current = true;
    } catch (err) {
      // ignore
    }
  }, [isOpen, isAdUnitRendered]);

  const adUnitRefCallback: LegacyRef<HTMLDivElement> = (element) => {
    adUnitRef.current = element;
    setIsAdUnitRendered(!!element);
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Title>Statistik</Modal.Title>
      <div className="grid grid-rows-1 grid-cols-4 text-center w-3/4 space-x-1 mx-auto mb-8">
        <div>
          <div className="text-md sm:text-xl lg:text-3xl">{totalPlay}</div>
          <div className="text-xs md:text-sm break-word">Dimainkan</div>
        </div>
        <div>
          <div className="text-md sm:text-xl lg:text-3xl">
            {totalPlay === 0 ? 0 : Math.round((totalWin / totalPlay) * 100)}
          </div>
          <div className="text-xs md:text-sm break-word">% Menang</div>
        </div>
        <div>
          <div className="text-md sm:text-xl lg:text-3xl">
            {stats.currentStreak}
          </div>
          <div className="text-xs md:text-sm break-word">Runtunan saat ini</div>
        </div>
        <div>
          <div className="text-md sm:text-xl lg:text-3xl">
            {stats.maxStreak}
          </div>
          <div className="text-xs md:text-sm break-word">Runtunan maksimum</div>
        </div>
      </div>
      <div className="w-10/12 mx-auto mb-8">
        <h3 className="uppercase font-semibold mb-4">Distribusi Tebakan</h3>
        {Array(6)
          .fill("")
          .map((_, i) => {
            const shouldHighlight = isAnswered && i === game.state.attempt - 1;
            const ratio =
              totalWin === 0
                ? GRAPH_WIDTH_MIN_RATIO
                : Math.max(
                    (Number(stats.distribution[i + 1]) / maxDistribution) * 100,
                    GRAPH_WIDTH_MIN_RATIO
                  );
            const alignment =
              ratio === GRAPH_WIDTH_MIN_RATIO
                ? "justify-center"
                : "justify-end";
            const background = shouldHighlight ? "bg-accent" : "bg-gray-500";
            return (
              <div className="flex h-5 mb-2" key={i}>
                <div className="tabular-nums">{i + 1}</div>
                <div className="w-full h-full pl-1">
                  <div
                    className={`text-right text-white ${background} flex ${alignment} px-2 font-bold`}
                    style={{ width: ratio + "%" }}
                  >
                    {stats.distribution[i + 1]}
                  </div>
                </div>
              </div>
            );
          })}
      </div>
      <div className="w-10/12 mx-auto mb-2" ref={adUnitRefCallback} />
      {showShare && (
        <>
          <WordDefinition answer={answer} />
          <div className="flex items-center justify-between w-3/4 m-auto my-8 space-x-2">
            {props.remainingTime ? (
              <TimeCounter time={props.remainingTime} />
            ) : (
              <div />
            )}
            <div className="bg-gray-400" style={{ width: 1 }}></div>
            <div className="flex flex-col space-y-4 text-white">
              <button
                onClick={handleShare}
                className="bg-accent py-1 md:py-3 px-3 md:px-6 rounded-md font-semibold uppercase text-xl flex flex-1 flex-row space-x-2 items-center justify-center"
              >
                <div>Share</div>
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  height="24"
                  viewBox="0 0 24 24"
                  width="24"
                >
                  <path
                    fill="currentColor"
                    d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92c0-1.61-1.31-2.92-2.92-2.92zM18 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM6 13c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm12 7.02c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"
                  ></path>
                </svg>
              </button>

              <button
                onClick={handleShareImage}
                className="bg-ig py-1 md:py-3 px-3 md:px-6 rounded-md font-semibold uppercase text-xl flex flex-1 flex-row space-x-2 items-center justify-center"
              >
                <div>Image</div>
                {canShareImage ? (
                  <svg
                    xmlns="http://www.w3.org/2000/svg"
                    height="24"
                    viewBox="0 0 24 24"
                    width="24"
                  >
                    <path
                      fill="currentColor"
                      d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92c0-1.61-1.31-2.92-2.92-2.92zM18 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM6 13c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm12 7.02c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"
                    ></path>
                  </svg>
                ) : (
                  <svg
                    xmlns="http://www.w3.org/2000/svg"
                    width="24"
                    height="24"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
                    <polyline points="7 10 12 15 17 10"></polyline>
                    <line x1="12" y1="15" x2="12" y2="3"></line>
                  </svg>
                )}
              </button>

              <label className="flex items-center gap-2 text-xs dark:text-gray-400 text-gray-600">
                <input
                  type="checkbox"
                  checked={showAnswersCheckbox}
                  onChange={(e) => setShowAnswersCheckbox(e.target.checked)}
                />
                <span>Sertakan jawaban pada gambar</span>
              </label>

              <button
                onClick={handleShareToTwitter}
                className="py-1 md:py-3 px-3 md:px-6 rounded-md font-semibold uppercase text-xl flex flex-1 flex-row space-x-2 items-center justify-center"
                style={{ backgroundColor: "#00acee" }}
              >
                <div>Tweet</div>
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  width="24"
                  height="24"
                  viewBox="0 0 24 24"
                  fill="#ffffff"
                >
                  <path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
                </svg>
              </button>
            </div>
          </div>
        </>
      )}
    </Modal>
  );
}

function WordDefinition({ answer }) {
  const { data = [] } = useSWR(`/api/define/${answer}`, (path) => {
    return fetcher(path, {
      headers: {
        Authorization: `token ${process.env.NEXT_PUBLIC_DEFINE_TOKEN}`,
      },
    });
  });

  return (
    <div className="w-10/12 mx-auto mb-8">
      <h3 className="uppercase font-semibold">Katla hari ini</h3>
      <p className="text-xs mb-2 dark:text-gray-400 text-gray-600">
        Mohon untuk tetap dirahasiakan, semua orang mendapatkan kata yang sama
        🙏
      </p>
      <p>
        <strong>{answer}</strong>
        {data.length > 0 ? (
          data.length === 1 ? (
            `: ${data[0]}`
          ) : (
            <ul className="text-sm">
              {data.map((d, i) => (
                <li className=" list-outside list-disc ml-6" key={i}>
                  {d}
                </li>
              ))}
            </ul>
          )
        ) : null}
      </p>
      <a
        className="color-accent text-sm"
        href={`https://kbbi.kemdikbud.go.id/entri/${answer}`}
      >
        Lihat di KBBI
      </a>
    </div>
  );
}

function TimeCounter({ time }: { time: ReturnType<typeof useRemainingTime> }) {
  const remainingTime = `${time.hours}:${pad0(time.minutes)}:${pad0(
    time.seconds
  )}`;

  return (
    <div className="text-center flex flex-1 flex-col">
      <div className="font-semibold uppercase text-xs md:text-md">
        Katla berikutnya
      </div>
      <div className="text-xl md:text-4xl">{remainingTime}</div>
    </div>
  );
}


================================================
FILE: components/Tile.tsx
================================================
import { CSSProperties, useEffect, useState } from "react";
import {
  FLIP_ANIMATION_DELAY_MS,
  FLIP_ANIMATION_DURATION_MS,
  SHAKE_ANIMATION_DURATION_MS,
} from "../utils/constants";
import { AnswerState } from "../utils/types";

interface Props {
  char: string;
  state: AnswerState;
  isInvalid?: boolean;
  delay: number;
  onPress?: () => void;
}

export default function Tile(props: Props) {
  const [background, setBackground] = useState("text-gray-700 dark:text-white");
  const [animate, setAnimationEnabled] = useState(false);
  const border =
    props.char === " " ? "border" : props.state === null ? "border-3" : "";
  const borderColor =
    props.char === " "
      ? "dark:border-gray-700 border-gray-400"
      : props.state === null
      ? "border-gray-500"
      : "";

  useEffect(() => {
    if (props.state === null) {
      return;
    }

    setAnimationEnabled(true);
    () => setAnimationEnabled(false);
  }, [props.state]);

  const style: CSSProperties = {};
  if (props.isInvalid) {
    style.animationName = "shake";
    style.animationDuration = `${SHAKE_ANIMATION_DURATION_MS}ms`;
  }

  if (animate) {
    style.animationName = "flip";
    style.animationDuration = `${FLIP_ANIMATION_DURATION_MS}ms`;
    style.animationDelay = `${props.delay}ms`;
  }

  useEffect(() => {
    setTimeout(() => {
      switch (props.state) {
        case "c":
          setBackground("text-white dark:text-gray-200 bg-correct");
          break;
        case "e":
          setBackground("text-white bg-exist");
          break;
        case "w":
          setBackground(
            "text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700"
          );
          break;
      }
    }, props.delay + FLIP_ANIMATION_DELAY_MS);
  }, [animate, props.state, props.delay]);

  return (
    <button
      style={style}
      className={`rounded-sm uppercase text-center h-full w-full text-dynamic font-bold ${background} flex justify-center items-center ${border} ${borderColor} select-none`}
      tabIndex={-1}
      onClick={props.onPress}
    >
      {props.char === "_" ? "" : props.char}
    </button>
  );
}


================================================
FILE: env.dev
================================================
NEXT_PUBLIC_DEFINE_TOKEN="test"


================================================
FILE: jest.config.js
================================================
/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

module.exports = {
  // All imported modules in your tests should be mocked automatically
  // automock: false,

  // Stop running tests after `n` failures
  // bail: 0,

  // The directory where Jest should store its cached dependency information
  // cacheDirectory: "/private/var/folders/s9/v3c_29w146qcg2v7f_pwmb3c0000gn/T/jest_dx",

  // Automatically clear mock calls, instances and results before every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  // collectCoverage: false,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  // collectCoverageFrom: undefined,

  // The directory where Jest should output its coverage files
  // coverageDirectory: undefined,

  // An array of regexp pattern strings used to skip coverage collection
  // coveragePathIgnorePatterns: [
  //   "/node_modules/"
  // ],

  // Indicates which provider should be used to instrument code for coverage
  coverageProvider: "v8",

  // A list of reporter names that Jest uses when writing coverage reports
  // coverageReporters: [
  //   "json",
  //   "text",
  //   "lcov",
  //   "clover"
  // ],

  // An object that configures minimum threshold enforcement for coverage results
  // coverageThreshold: undefined,

  // A path to a custom dependency extractor
  // dependencyExtractor: undefined,

  // Make calling deprecated APIs throw helpful error messages
  // errorOnDeprecated: false,

  // Force coverage collection from ignored files using an array of glob patterns
  // forceCoverageMatch: [],

  // A path to a module which exports an async function that is triggered once before all test suites
  // globalSetup: undefined,

  // A path to a module which exports an async function that is triggered once after all test suites
  // globalTeardown: undefined,

  // A set of global variables that need to be available in all test environments
  // globals: {},

  // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
  // maxWorkers: "50%",

  // An array of directory names to be searched recursively up from the requiring module's location
  // moduleDirectories: [
  //   "node_modules"
  // ],

  // An array of file extensions your modules use
  // moduleFileExtensions: [
  //   "js",
  //   "jsx",
  //   "ts",
  //   "tsx",
  //   "json",
  //   "node"
  // ],

  // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
  // moduleNameMapper: {},

  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
  // modulePathIgnorePatterns: [],

  // Activates notifications for test results
  // notify: false,

  // An enum that specifies notification mode. Requires { notify: true }
  // notifyMode: "failure-change",

  // A preset that is used as a base for Jest's configuration
  // preset: undefined,

  // Run tests from one or more projects
  // projects: undefined,

  // Use this configuration option to add custom reporters to Jest
  // reporters: undefined,

  // Automatically reset mock state before every test
  // resetMocks: false,

  // Reset the module registry before running each individual test
  // resetModules: false,

  // A path to a custom resolver
  // resolver: undefined,

  // Automatically restore mock state and implementation before every test
  // restoreMocks: false,

  // The root directory that Jest should scan for tests and modules within
  // rootDir: undefined,

  // A list of paths to directories that Jest should use to search for files in
  // roots: [
  //   "<rootDir>"
  // ],

  // Allows you to use a custom runner instead of Jest's default test runner
  // runner: "jest-runner",

  // The paths to modules that run some code to configure or set up the testing environment before each test
  // setupFiles: [],

  // A list of paths to modules that run some code to configure or set up the testing framework before each test
  // setupFilesAfterEnv: [],

  // The number of seconds after which a test is considered as slow and reported as such in the results.
  // slowTestThreshold: 5,

  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
  // snapshotSerializers: [],

  // The test environment that will be used for testing
  // testEnvironment: "jest-environment-node",

  // Options that will be passed to the testEnvironment
  // testEnvironmentOptions: {},

  // Adds a location field to test results
  // testLocationInResults: false,

  // The glob patterns Jest uses to detect test files
  // testMatch: [
  //   "**/__tests__/**/*.[jt]s?(x)",
  //   "**/?(*.)+(spec|test).[tj]s?(x)"
  // ],

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  // testPathIgnorePatterns: [
  //   "/node_modules/"
  // ],

  // The regexp pattern or array of patterns that Jest uses to detect test files
  // testRegex: [],

  // This option allows the use of a custom results processor
  // testResultsProcessor: undefined,

  // This option allows use of a custom test runner
  // testRunner: "jest-circus/runner",

  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
  // testURL: "http://localhost",

  // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
  // timers: "real",

  // A map from regular expressions to paths to transformers
  transform: {
    "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }],
  },

  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
  // transformIgnorePatterns: [
  //   "/node_modules/",
  //   "\\.pnp\\.[^\\/]+$"
  // ],

  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
  // unmockedModulePathPatterns: undefined,

  // Indicates whether each individual test should be reported during the run
  // verbose: undefined,

  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
  // watchPathIgnorePatterns: [],

  // Whether to use watchman for file crawling
  // watchman: true,
};


================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.


================================================
FILE: next.config.js
================================================
const { withSentryConfig } = require("@sentry/nextjs");

const nextConfig = {
  reactStrictMode: true,
};

const sentryWebpackPluginOptions = {
  silent: true,
};

module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);


================================================
FILE: package.json
================================================
{
  "name": "katla",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest",
    "update": "node ./.scripts/update.js",
    "format": "prettier --write '**/*.{js,md,json,yml,yaml,css,ts,tsx}'",
    "prepare": "husky install"
  },
  "lint-staged": {
    "**/*": "prettier --write --ignore-unknown"
  },
  "dependencies": {
    "@liveblocks/client": "^0.14.1",
    "@liveblocks/node": "^0.3.0",
    "@liveblocks/react": "^0.14.1",
    "@reach/dialog": "^0.16.2",
    "@reach/menu-button": "^0.16.2",
    "@sentry/nextjs": "^6.17.4",
    "@supabase/supabase-js": "^1.30.7",
    "canvas-confetti": "^1.4.0",
    "cheerio": "^1.0.0-rc.10",
    "date-fns": "^2.28.0",
    "file-saver": "^2.0.5",
    "next": "^12.1.0",
    "next-themes": "^0.0.16",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-hot-toast": "^2.2.0",
    "swr": "^1.1.2"
  },
  "devDependencies": {
    "@octokit/rest": "^18.12.0",
    "@testing-library/react-hooks": "^7.0.2",
    "@types/react": "^17.0.38",
    "autoprefixer": "^10.4.2",
    "eslint": "8.7.0",
    "eslint-config-next": "12.1.0",
    "husky": "^7.0.4",
    "jest": "^27.4.7",
    "lint-staged": "^12.2.2",
    "postcss": "^8.4.5",
    "prettier": "^2.5.1",
    "tailwindcss": "^3.0.15",
    "typescript": "^4.5.4"
  }
}


================================================
FILE: pages/_app.tsx
================================================
import Script from "next/script";

import "../styles/globals.css";
import { ThemeProvider } from "next-themes";
import Alert from "../components/Alert";
import EmojiRain from "../components/EmojiRain";

export default function MyApp({ Component, pageProps }) {
  return (
    <ThemeProvider storageKey="katla:theme" attribute="class">
      <Alert />
      <EmojiRain />
      <Component {...pageProps} />
      <Script id="track-ga" strategy="afterInteractive">{`
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());
        gtag('config', 'G-QNLF4HTK6S');
      `}</Script>
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=G-QNLF4HTK6S"
        strategy="afterInteractive"
      />
    </ThemeProvider>
  );
}


================================================
FILE: pages/_document.tsx
================================================
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="id" translate="no">
      <Head>
        <meta name="google-adsense-account" content="ca-pub-3081263972680635" />
        <script
          async
          src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3081263972680635"
          crossOrigin="anonymous"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}


================================================
FILE: pages/_error.js
================================================
import NextErrorComponent from "next/error";

import * as Sentry from "@sentry/nextjs";

const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {
  if (!hasGetInitialPropsRun && err) {
    // getInitialProps is not called in case of
    // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
    // err via _app.js so it can be captured
    Sentry.captureException(err);
    // Flushing is not required in this case as it only happens on the client
  }

  return <NextErrorComponent statusCode={statusCode} />;
};

MyError.getInitialProps = async (context) => {
  const errorInitialProps = await NextErrorComponent.getInitialProps(context);

  const { res, err, asPath } = context;

  // Workaround for https://github.com/vercel/next.js/issues/8592, mark when
  // getInitialProps has run
  errorInitialProps.hasGetInitialPropsRun = true;

  // Returning early because we don't want to log 404 errors to Sentry.
  if (res?.statusCode === 404) {
    return errorInitialProps;
  }

  // Running on the server, the response object (`res`) is available.
  //
  // Next.js will pass an err on the server if a page's data fetching methods
  // threw or returned a Promise that rejected
  //
  // Running on the client (browser), Next.js will provide an err if:
  //
  //  - a page's `getInitialProps` threw or returned a Promise that rejected
  //  - an exception was thrown somewhere in the React lifecycle (render,
  //    componentDidMount, etc) that was caught by Next.js's React Error
  //    Boundary. Read more about what types of exceptions are caught by Error
  //    Boundaries: https://reactjs.org/docs/error-boundaries.html

  if (err) {
    Sentry.captureException(err);

    // Flushing before returning is necessary if deploying to Vercel, see
    // https://vercel.com/docs/platform/limits#streaming-responses
    await Sentry.flush(2000);

    return errorInitialProps;
  }

  // If this point is reached, getInitialProps was called without any
  // information about what the error might be. This is unexpected and may
  // indicate a bug introduced in Next.js, so record it in Sentry
  Sentry.captureException(
    new Error(`_error.js getInitialProps missing data at path: ${asPath}`)
  );
  await Sentry.flush(2000);

  return errorInitialProps;
};

export default MyError;


================================================
FILE: pages/api/define/[word].ts
================================================
import { withSentry } from "@sentry/nextjs";
import cheerio from "cheerio";
import { NextApiRequest, NextApiResponse } from "next";

interface Definition {
  def_text: string;
}

interface KategloResponse {
  kateglo: {
    definition: ArrayLike<Definition>;
  };
}

const tokens = [
  process.env.NEXT_PUBLIC_DEFINE_TOKEN,
  process.env.THIRD_PARTY_DEFINE_TOKEN,
];

async function handler(req: NextApiRequest, res: NextApiResponse) {
  const word = req.query.word as string;
  const auth = req.headers.authorization;

  if (!auth || !auth.startsWith("token")) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  const [_, token] = auth.split(" ");
  if (!tokens.includes(token)) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  let definitions: String[] | null = null;
  try {
    try {
      definitions = await fetchFromMakna(word);
    } catch (err) {
      console.log(`Failed to fetch from makna, using KBBI for word ${word}`, {
        err,
      });
      definitions = await fetchFromKbbi(word);
    }
  } catch (err) {
    console.warn(
      `Failed to fetch definitions from KBBI, using kateglo.com for word ${word}`,
      { err }
    );
    try {
      const kateglo: KategloResponse = await fetch(
        `https://kateglo.com/api.php?format=json&phrase=${word}`
      ).then((res) => res.json());
      definitions = Array.from(kateglo.kateglo.definition).map(
        (d) => d.def_text
      );
    } catch (err) {
      definitions = null;
    }
  }

  if (definitions === null) {
    res.status(500).json({ error: "Failed to get definitions" });
    return;
  }

  res.setHeader(
    "Cache-Control",
    "public, s-maxage=21600, stale-while-revalidate=86400"
  );
  res.status(200).json(definitions);
}

export default withSentry(handler);

async function fetchFromMakna(word: string): Promise<string[]> {
  const json = await fetch(
    `https://makna.fatihkalifa.workers.dev/${word}.json`
  ).then((res) => res.json());
  return json.flatMap((entry) => {
    return entry.makna.map((makna) => makna.definisi);
  });
}

async function fetchFromKbbi(word: string): Promise<string[]> {
  const html = await fetch(`https://kbbi.kemdikbud.go.id/entri/${word}`).then(
    (res) => res.text()
  );
  const $ = cheerio.load(html);

  const definitions = [];
  $("ol li, ul.adjusted-par li").each((i, el) => {
    $(el).find("font").remove();
    definitions.push($(el).text());
  });

  return definitions;
}


================================================
FILE: pages/api/live.ts
================================================
import { authorize } from "@liveblocks/node";
import { createClient } from "@supabase/supabase-js";
import { NextApiRequest, NextApiResponse } from "next";

const supabase = createClient(
  "https://wwaoyidihlwhhlzykzup.supabase.co",
  process.env.SUPABASE_SECRET
);

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const room = req.body.room;
  const auth = req.body.auth;

  const { data, error } = await supabase
    .from("rooms")
    .select()
    .or(`auth.eq.${auth},invite.eq.${auth}`);

  if (error || data.length === 0) {
    return res
      .status(403)
      .json({ error: "You don't have permission to access this room" });
  }

  const result = await authorize({
    room,
    secret: process.env.LIVEBLOCKS_SECRET_KEY,
    userId: req.body.username,
  });

  return res.status(result.status).json({
    ...JSON.parse(result.body),
    inviteKey: data[0].invite,
  });
}


================================================
FILE: pages/api/words.ts
================================================
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const kbbi = await fetch("https://kbbi.vercel.app").then((res) => res.json());
  const words = kbbi.entries
    .map((entry) => {
      const [word] = entry.split("/").reverse();
      return word;
    })
    .filter((word) => /^[a-z]+$/.test(word) && word.length === 5)
    .concat(october2021)
    .sort();

  res.setHeader(
    "Cache-Control",
    "public, s-maxage=3600, stale-while-revalidate=86400"
  );
  res.status(200).json(Array.from(new Set(words)));
}

const october2021 = [
  "abate",
  "abjat",
  "ampet",
  "arame",
  "asrot",
  "azeri",
  "azuki",
  "bakes",
  "benin",
  "beton",
  "bezit",
  "bksda",
  "botia",
  "burma",
  "cekah",
  "cekat",
  "cenah",
  "colon",
  "dapuk",
  "datau",
  "datin",
  "datum",
  "denar",
  "dobra",
  "doula",
  "duvet",
  "filem",
  "folio",
  "gabut",
  "gayor",
  "gokil",
  "gravi",
  "hipmi",
  "ilyas",
  "impun",
  "india",
  "islan",
  "kabul",
  "kalke",
  "kazak",
  "korea",
  "kudet",
  "kurdi",
  "kwaca",
  "lakip",
  "mandu",
  "maori",
  "nuget",
  "ompok",
  "palau",
  "panda",
  "pasto",
  "porus",
  "rasis",
  "rouks",
  "rupst",
  "sango",
  "sapun",
  "silpa",
  "sonde",
  "struk",
  "sudan",
  "swazi",
  "tafia",
  "tajik",
  "tando",
  "tenge",
  "titis",
  "tonga",
  "tuhur",
  "uwete",
  "uzbek",
  "venda",
];


================================================
FILE: pages/arsip/[num].tsx
================================================
import { GetStaticPaths, GetStaticProps } from "next";
import { ComponentProps, useState } from "react";

import App from "../../components/App";
import Container from "../../components/Container";
import Header from "../../components/Header";
import HeadingWithNum from "../../components/HeadingWithNum";
import HelpModal from "../../components/HelpModal";
import { useModalState } from "../../components/Modal";
import SettingsModal from "../../components/SettingsModal";
import StatsModal from "../../components/StatsModal";

import { getAllAnswers } from "../../utils/answers";
import { encodeHashed } from "../../utils/codec";
import fetcher from "../../utils/fetcher";
import { isGameFinished, useGame } from "../../utils/game";
import { handleGameComplete, handleSubmitWord } from "../../utils/message";
import { GameStats } from "../../utils/types";

interface Props {
  num: string;
  hashed: string;
  words: string[];
}

const initialStats: GameStats = {
  distribution: {
    1: 0,
    2: 0,
    3: 0,
    4: 0,
    5: 0,
    6: 0,
    fail: 0,
  },
  currentStreak: 0,
  maxStreak: 0,
};

export default function Arsip(props: Props) {
  const game = useGame(props.hashed, false);
  const [stats, setStats] = useState(initialStats);
  const [modalState, setModalState, resetModalState] = useModalState(
    game,
    stats
  );

  const headerProps: ComponentProps<typeof Header> = {
    title: `Katla | Arsip #${props.num}`,
    customHeading: <HeadingWithNum num={props.num} />,
    path: `/arsip/${props.num}`,
    ogImage: "https://katla.vercel.app/og-arsip.png",
    themeColor: game.state.enableHighContrast ? "#f5793a" : "#15803D",
    onShowHelp: () => setModalState("help"),
    onShowStats: isGameFinished(game)
      ? () => setModalState("stats")
      : undefined,
    onShowSettings: () => setModalState("settings"),
  };

  if (!game.ready) {
    return (
      <Container>
        <Header {...headerProps} />
      </Container>
    );
  }

  return (
    <Container>
      <Header {...headerProps} />
      <App
        game={game}
        stats={stats}
        setStats={setStats}
        showStats={() => setModalState("stats")}
        words={props.words}
        onSubmit={handleSubmitWord}
        onComplete={handleGameComplete}
      />
      <HelpModal isOpen={modalState === "help"} onClose={resetModalState} />
      <StatsModal
        game={game}
        stats={stats}
        isOpen={modalState === "stats"}
        onClose={resetModalState}
      />
      <SettingsModal
        isOpen={modalState === "settings"}
        onClose={resetModalState}
        game={game}
      />
    </Container>
  );
}

// generate first and last 5 days
export const getStaticPaths: GetStaticPaths = async () => {
  const answers = await getAllAnswers();

  return {
    paths: Array.from({ length: 5 }, (_, i) => ({
      params: { num: `${i + 1}` },
    })).concat(
      Array.from({ length: 5 }, (_, i) => ({
        params: { num: `${answers.length - i + 1}` },
      }))
    ),
    fallback: "blocking",
  };
};

export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
  const num = ctx.params.num as string;
  if (Number.isNaN(parseInt(num))) {
    return {
      notFound: true,
    };
  }

  const numInt = Number(num);

  const [answers, words] = await Promise.all([
    getAllAnswers(),
    fetcher("https://makna.fatihkalifa.workers.dev/words.json"),
  ]);

  // archive should only return previous dates
  if (numInt > answers.length) {
    return {
      notFound: true,
      revalidate: 60,
    };
  }

  return {
    props: {
      num,
      hashed: encodeHashed(numInt, answers[numInt - 1], ""),
      words,
    },
  };
};


================================================
FILE: pages/arsip/index.tsx
================================================
import { useState } from "react";

import Container from "../../components/Container";
import Header from "../../components/Header";
import HelpModal from "../../components/HelpModal";
import Link from "../../components/Link";
import SettingsModal from "../../components/SettingsModal";

import { getAllAnswers } from "../../utils/answers";
import { initialState, useGamePersistedState } from "../../utils/game";
import { Game } from "../../utils/types";

export default function Arsip({ nums }) {
  const [modalState, setModalState] = useState(null);
  const [gameState, setGameState] = useGamePersistedState(initialState);
  const game: Game = {
    hash: "",
    num: -1,
    migrate: () => {},
    state: gameState,
    setState: setGameState,
    ready: true,
    readyState: "ready" as const,
    trackInvalidWord: () => {},
  };

  return (
    <Container>
      <Header
        path="/arsip"
        title="Katla | Arsip"
        keywords={[
          "arsip",
          "archive",
          "game",
          "permainan",
          "tebak",
          "kata",
          "rahasia",
          "wordle",
          "indonesia",
          "kbbi",
        ]}
        ogImage="https://katla.vercel.app/og-arsip.png"
        onShowHelp={() => setModalState("help")}
        onShowSettings={() => setModalState("settings")}
      />
      <div className="px-4 mx-auto max-w-lg w-full pt-2 pb-4 text-left">
        <h2 className="text-2xl font-semibold mb-4">Arsip</h2>
        <p className="mb-2">
          Berikut adalah daftar kata telah digunakan sebelumnya. Kamu bisa
          menggunakan <em>link</em> di bawah, atau langsung memasukkan alamat
          pada <em>address bar</em> sesuai angka hari, misal:{" "}
          <a href="https://katla.vercel.app/arsip/1" className="color-accent">
            https://katla.vercel.app/arsip/1
          </a>
        </p>
        <p className="mb-2">
          Arsip hanya mencakup daftar di masa lalu dan tidak dapat digunakan
          untuk melihat masa depan 😌
        </p>
        <ol className="mx-8 list-disc">
          {Array(nums)
            .fill("")
            .map((_, i) => (
              <li key={i}>
                <Link href={`/arsip/${i + 1}`}>{`Hari ke-${i + 1}`}</Link>
              </li>
            ))}
        </ol>
      </div>
      <HelpModal
        isOpen={modalState === "help"}
        onClose={() => setModalState(null)}
      />
      <SettingsModal
        game={game}
        isOpen={modalState === "settings"}
        onClose={() => setModalState(null)}
      />
    </Container>
  );
}

export const getStaticProps = async () => {
  const answers = await getAllAnswers();
  return {
    props: {
      nums: answers.length - 1,
    },
    revalidate: 3600,
  };
};


================================================
FILE: pages/bantuan.tsx
================================================
import { FormEvent, useEffect, useState } from "react";
import LocalStorage from "../utils/browser";
import {
  GAME_STATE_KEY,
  GAME_STATS_KEY,
  INVALID_WORDS_KEY,
  LAST_HASH_KEY,
  LAST_SESSION_RESET_KEY,
} from "../utils/constants";

export default function Debug(props: { hashed: string }) {
  const [debugCode, setDebugCode] = useState("");
  useEffect(() => {
    const gameState = LocalStorage.getItem(GAME_STATE_KEY);
    const gameStats = LocalStorage.getItem(GAME_STATS_KEY);
    const lastHash = LocalStorage.getItem(LAST_HASH_KEY);
    const invalidWords = LocalStorage.getItem(INVALID_WORDS_KEY);
    const lastSessionReset = LocalStorage.getItem(LAST_SESSION_RESET_KEY);
    const now = new Date();
    let timezone = "Unknown";
    try {
      timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
    } catch (err) {}

    setDebugCode(
      btoa(
        [
          props.hashed,
          lastHash,
          gameState,
          gameStats,
          invalidWords,
          lastSessionReset,
          now.getTime(),
          now.getTimezoneOffset(),
          timezone,
          navigator.userAgent,
          window.location.host,
        ].join(":")
      )
    );
    // eslint-disable-next-line
  }, []);

  const messagePrefix = `Halo, saya ingin melaporkan masalah tentang ...`;
  const mailToLink = `mailto:help@katla.id?subject=Problem katla&body=${messagePrefix}%0D%0A%0D%0AKode: ${debugCode}`;

  const confirmImport = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // @ts-ignore
    const newDebugCode = e.target.debugCode.value;
    let confirmed: boolean;
    if (newDebugCode === debugCode) {
      confirmed = window.confirm(
        "Kode yang anda masukkan sama dengan kode di perangkat ini. Apakah anda yakin ingin mengimpor?"
      );
    } else {
      confirmed = window.confirm(
        "Statistik yang ada di perangkat ini akan diganti dari statistik dari kode. Apakah anda yakin?"
      );
    }

    if (!confirmed) return;

    try {
      const decoded = atob(newDebugCode);
      const st0 = decoded.indexOf('{"distribution');
      const ste = decoded.indexOf("}:");
      const stats = decoded.slice(st0, ste + 1);
      LocalStorage.setItem(GAME_STATS_KEY, stats);
      alert("Statistik berhasil diimpor");
      window.location.replace("/");
    } catch (err) {
      console.error(err);
      alert("Kode yang anda masukkan tidak valid");
    }
  };

  return (
    <div className="dark:text-white max-w-lg mx-auto mt-4 px-3">
      <NewSiteWarning />
      <h1 className="text-3xl mb-4">Bantuan</h1>
      {debugCode === "" ? (
        <span>Generating debug code...</span>
      ) : (
        <>
          <p className="mb-4">
            Klik{" "}
            <a className="underline text-blue-400" href={mailToLink}>
              tautan berikut
            </a>{" "}
            untuk mengirim email.
          </p>
          <p className="mb-4">
            Klik {/* eslint-disable-next-line */}
            <a href="/" className="underline text-blue-400">
              tautan berikut
            </a>{" "}
            untuk kembali ke beranda
          </p>
          <strong>Kode bantuan</strong>
          <pre className="border border-gray-300 p-3 whitespace-pre-wrap break-all ">
            {debugCode}
          </pre>
        </>
      )}
      <h2 className="text-2xl mt-4 mb-4">Impor Statistik</h2>
      <p className="mb-4">
        Masukkan debug code yang anda dapat dari halaman ini di perangkat lain
        untuk mengimpor statistik dari perangkat tersebut
      </p>
      <form onSubmit={confirmImport}>
        <textarea
          name="debugCode"
          className="w-full h-64 border border-gray-300 p-2 rounded-r overflow-hidden"
          placeholder="Salin kode di sini"
        />
        <button
          type="submit"
          className="border-none px-3 py-1 bg-accent text-white rounded-sm overflow-hidden mb-4"
        >
          Impor
        </button>
      </form>
    </div>
  );
}

function NewSiteWarning() {
  return (
    <div className="mx-auto text-sm mb-4 p-3 bg-yellow-100 text-black rounded-sm overflow-hidden">
      <p className="mb-2">
        Mulai 4 Oktober 2023, Katla akan menggunakan domain baru di{" "}
        <a href="https://katla.id" className="underline">
          katla.id
        </a>
        . Statistik permainan anda akan dipindahkan secara otomatis.
      </p>
    </div>
  );
}

export { getStaticProps } from "./index";


================================================
FILE: pages/index.tsx
================================================
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises";
import { GetStaticProps } from "next";
import { useRouter } from "next/router";
import path from "path";
import { ComponentProps, useEffect } from "react";

import App from "../components/App";
import Container from "../components/Container";
import Header from "../components/Header";
import HeadingWithNum from "../components/HeadingWithNum";
import HelpModal from "../components/HelpModal";
import { useModalState } from "../components/Modal";
import SettingsModal from "../components/SettingsModal";
import SponsorshipFooter from "../components/SponsorshipFooter";
import StatsModal from "../components/StatsModal";

import LocalStorage from "../utils/browser";
import { encodeHashed } from "../utils/codec";
import { GAME_STATS_KEY, LAST_HASH_KEY } from "../utils/constants";
import fetcher from "../utils/fetcher";
import { getTotalPlay, useGame, useRemainingTime } from "../utils/game";
import { handleGameComplete, handleSubmitWord } from "../utils/message";
import { trackEvent } from "../utils/tracking";
import { GameStats, MigrationData } from "../utils/types";
import createStoredState from "../utils/useStoredState";

interface Props {
  hashed: string;
  words: string[];
}

const initialStats: GameStats = {
  distribution: {
    1: 0,
    2: 0,
    3: 0,
    4: 0,
    5: 0,
    6: 0,
    fail: 0,
  },
  currentStreak: 0,
  maxStreak: 0,
};

const useStats = createStoredState<GameStats>(GAME_STATS_KEY);

const VALID_STATS_DELAY_MS = 5000;

export default function Home(props: Props) {
  const remainingTime = useRemainingTime();
  const game = useGame(props.hashed);
  const [stats, setStats] = useStats(initialStats);
  const [modalState, setModalState, resetModalState] = useModalState(
    game,
    stats
  );

  const router = useRouter();

  useEffect(() => {
    const migrationData = router.query.migrate;
    if (!migrationData) {
      return;
    }

    let data: MigrationData;
    try {
      data = JSON.parse(decodeURIComponent(migrationData as string));
    } catch (err) {
      Sentry.captureException(err, { extra: { migrationData } });
      return;
    }

    const timeDiff = Date.now() - data.time;
    if (timeDiff > VALID_STATS_DELAY_MS) {
      trackEvent("invalidMigrationTime", { timeDiff });
      router.replace("/");
      return;
    }

    const hasExistingData = checkExistingData(data.stats);

    let shouldContinue = true;
    if (hasExistingData) {
      shouldContinue = window.confirm(
        `Kamu sudah memiliki statistik yang tersimpan di katla.id, apakah kamu ingin menggantinya dengan statistik terakhir dari katla.vercel.app?`
      );
    }

    if (!shouldContinue) {
      trackEvent("migrationCancelled", {
        hasExistingData: hasExistingData.toString(),
      });
      router.replace("/");
      return;
    }

    LocalStorage.setItem(GAME_STATS_KEY, JSON.stringify(data.stats));
    LocalStorage.setItem(LAST_HASH_KEY, data.lastHash);
    setStats(data.stats);
    trackEvent("migrationSuccess", {});
    router.replace("/");
  }, [router]);

  const headerProps: ComponentProps<typeof Header> = {
    customHeading: (
      <HeadingWithNum
        num={game.ready ? game.num : null}
        enableLiarMode={game.state.enableLiarMode}
      />
    ),
    themeColor: game.state.enableHighContrast ? "#f5793a" : "#15803D",
    onShowHelp: () => setModalState("help"),
    onShowStats: () => setModalState("stats"),
    onShowSettings: () => setModalState("settings"),
    showLiarOption: game.ready && game.num === 71 && !game.state.enableLiarMode,
  };

  if (game.readyState === "init") {
    return (
      <Container>
        <Header {...headerProps} />
      </Container>
    );
  }

  return (
    <Container>
      <Header
        {...headerProps}
        warnStorageDisabled={game.readyState === "no-storage"}
      />
      <App
        game={game}
        stats={stats}
        setStats={setStats}
        showStats={() => setModalState("stats")}
        words={props.words}
        onSubmit={handleSubmitWord}
        onComplete={handleGameComplete}
      />
      <HelpModal isOpen={modalState === "help"} onClose={resetModalState} />
      <StatsModal
        isOpen={modalState === "stats"}
        onClose={resetModalState}
        game={game}
        stats={stats}
        remainingTime={remainingTime}
      />
      <SettingsModal
        isOpen={modalState === "settings"}
        onClose={resetModalState}
        game={game}
      />
      <SponsorshipFooter />
    </Container>
  );
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const [answers, words] = await Promise.all([
    fs
      .readFile(path.join(process.cwd(), "./.scripts/answers.csv"), "utf8")
      .then((text) =>
        text
          .split(",")
          .map((s) => s.trim())
          .filter(Boolean)
      ),
    fetcher("https://makna.fatihkalifa.workers.dev/words.json"),
  ]);

  return {
    props: {
      hashed: encodeHashed(
        answers.length,
        answers[answers.length - 1],
        answers[answers.length - 2]
      ),
      words: words,
    },
    revalidate: 60,
  };
};

const checkExistingData = (newStats: GameStats) => {
  if (!LocalStorage.getItem(GAME_STATS_KEY)) {
    return false;
  }

  try {
    const currentStats: GameStats = JSON.parse(
      LocalStorage.getItem(GAME_STATS_KEY) as string
    );
    const totalPlay = getTotalPlay(currentStats);
    const newTotalPlay = getTotalPlay(newStats);
    if (totalPlay !== newTotalPlay) {
      return true;
    }

    if (currentStats.maxStreak !== newStats.maxStreak) {
      return true;
    }

    if (currentStats.currentStreak !== newStats.currentStreak) {
      return true;
    }

    for (const v in currentStats.distribution) {
      if (currentStats.distribution[v] !== newStats.distribution[v]) {
        return true;
      }
    }

    return false;
  } catch (err) {
    return false;
  }
};


================================================
FILE: pages/lawan.tsx
================================================
import { createClient } from "@liveblocks/client";
import {
  LiveblocksProvider,
  RoomProvider,
  useBroadcastEvent,
  useOthers,
  useSelf,
} from "@liveblocks/react";
import { GetStaticProps } from "next";
import {
  ComponentProps,
  FormEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from "react";

import App from "../components/App";
import Container from "../components/Container";
import Header from "../components/Header";
import Modal, { useModalState } from "../components/Modal";

import { useTheme } from "next-themes";
import HelpModal from "../components/HelpModal";
import LiveStatsModal from "../components/LiveStatsModal";
import SettingsModal from "../components/SettingsModal";
import { decode, encode } from "../utils/codec";
import fetcher from "../utils/fetcher";
import {
  defaultScore,
  generateRoomId,
  getEmojiFromScore,
  getTotalScore,
  LiveGame,
  shareInviteLink,
  useLiveGame,
} from "../utils/liveGame";
import { GameStats, LiveConfig } from "../utils/types";

interface Props {
  words: string[];
}

export default function Lawan({ words }: Props) {
  const [username, setUsername] = useState("");
  const [roomId, setRoomId] = useState("");
  const [isHost, setIsHost] = useState(false);
  const [inviteKey, setInviteKey] = useState(null);
  const client = useRef<ReturnType<typeof createClient>>(null);

  useEffect(() => {
    const query = new URLSearchParams(window.location.search);
    const room = query.get("room");
    const auth = query.get("auth");
    const invite = query.get("invite");

    if (!room && auth) {
      query.set("room", generateRoomId(encode(auth)));
      window.location.search = query.toString();
      return;
    }

    if (room) {
      setRoomId(room);

      const [_, eauth, _id] = room.split("-");
      if (auth) {
        if (decode(eauth) !== auth) {
          window.location.replace("/404");
          return;
        }

        setIsHost(true);
        return;
      }

      if (!invite) {
        window.location.replace("/404");
        return;
      }
    }
  }, []);

  if (!roomId) {
    return <div>{"loading..."}</div>;
  }

  function handleSubmit(e: FormEvent) {
    const username = (e.target as any).name.value;
    e.preventDefault();
    setUsername(username);
    client.current = createClient({
      authEndpoint: async (room) => {
        const query = new URLSearchParams(window.location.search);
        const response = await fetch("/api/live", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            room,
            auth: query.get("auth") ?? query.get("invite"),
            username,
          }),
        });

        // TODO: validate
        const { inviteKey, ...liveblocks } = await response.json();
        setInviteKey(inviteKey);
        return liveblocks;
      },
    });
  }

  if (!username) {
    return (
      <Modal isOpen>
        <form onSubmit={handleSubmit}>
          <label htmlFor="username" className="mb-4 block">
            Masukkan username
          </label>
          <input
            id="username"
            className="text-gray-800 dark:text-gray-100 p-2 mr-4 rounded-sm bg-gray-200 dark:bg-gray-800"
            name="name"
            autoComplete="off"
          />
          <button className="px-4 py-2 rounded-sm bg-green-600">Pilih</button>
        </form>
      </Modal>
    );
  }

  return (
    <LiveblocksProvider client={client.current}>
      <RoomProvider id={roomId as string}>
        <Main
          words={words}
          config={{
            isHost,
            inviteKey,
            roomId,
          }}
        />
      </RoomProvider>
    </LiveblocksProvider>
  );
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const words = await fetcher(
    "https://makna.fatihkalifa.workers.dev/words.json"
  );
  return {
    props: {
      words,
    },
  };
};

const initialStats: GameStats = {
  distribution: {
    1: 0,
    2: 0,
    3: 0,
    4: 0,
    5: 0,
    6: 0,
    fail: 0,
  },
  currentStreak: 0,
  maxStreak: 0,
};

interface MainProps {
  words: string[];
  config: LiveConfig;
}

function Main({ words, config }: MainProps) {
  const game = useLiveGame(words);
  const [stats, setStats] = useState(initialStats);
  const broadcast = useBroadcastEvent();
  const others = useOthers();
  const self = useSelf();
  const [modalState, setModalState, resetModalState] = useModalState(
    game,
    stats
  );

  const handleSendEmoji = useCallback(
    (emoji: string) => {
      broadcast({ type: "emoji", emoji, username: self.id });
    },
    [broadcast, self?.id]
  );

  const headerProps: ComponentProps<typeof Header> = {
    path: "/lawan",
    onSendEmoji: others.count > 0 ? handleSendEmoji : undefined,
    customHeading: (
      <div className="dark:text-gray-300 text-gray-700 flex relative">
        <div className="uppercase text-center flex flex-col items-center">
          <span className="block">Lawan</span>
          <span className="block text-xs" style={{ letterSpacing: 0 }}>
            Katla
          </span>
        </div>
        {game.ready && game.num > 0 && (
          <sup className="top-1 tracking-tight" style={{ fontSize: "45%" }}>
            #{game.num}
          </sup>
        )}
      </div>
    ),
    onShowHelp: () => setModalState("help"),
    onShowStats: () => setModalState("stats"),
    onShowSettings: () => setModalState("settings"),
    isLiveMode: true,
  };

  const playerCount = others.toArray().length + 1;
  const isReady = playerCount > 1;

  useEffect(() => {
    if (isReady && game.num === 0 && config.isHost) {
      game.start();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isReady, game.num]);

  return (
    <Container>
      <Header {...headerProps} />
      <LiveGameBar game={game} config={config} isReady={isReady} />
      {game.hash && isReady && (
        <App
          game={game}
          stats={stats}
          setStats={setStats}
          showStats={() => void 0}
          words={words}
        />
      )}
      <HelpModal
        isOpen={modalState === "help"}
        onClose={resetModalState}
        isLiveMode
      />
      <LiveStatsModal
        isOpen={modalState === "stats"}
        onClose={resetModalState}
        totalPlay={game.num ?? 0}
      />
      <SettingsModal
        isOpen={modalState === "settings"}
        onClose={resetModalState}
        game={game}
        liveConfig={config}
      />
    </Container>
  );
}

interface GameBarProps {
  game: LiveGame;
  config: LiveConfig;
  isReady: boolean;
}

function LiveGameBar(props: GameBarProps) {
  const { game, config, isReady } = props;
  const { resolvedTheme } = useTheme();
  const others = useOthers();
  const currentUser = useSelf();

  const users = others.toArray().concat(currentUser).filter(Boolean);

  const userScores = users
    .map((user) => ({
      id: user.id,
      scores: user.presence?.scores ?? defaultScore,
      isFailed: user.presence?.isFailed ?? false,
    }))
    .sort((a, b) => {
      return getTotalScore(a.scores) > getTotalScore(b.scores) ? -1 : 1;
    });

  function handleShare() {
    shareInviteLink(config);
  }

  return (
    <div className="relative z-auto pb-4" id="game-bar">
      {isReady ? (
        <div className="flex flex-row overflow-x-auto">
          {userScores.map((entry) => (
            <div
              key={entry.id}
              className="flex-grow-0 flex-shrink-0 user-score self-center px-2"
              style={{ maxWidth: 150, opacity: entry.isFailed ? 0.4 : 1 }}
            >
              <span className="text-ellipsis block overflow-clip">
                {entry.id}
              </span>
              <div className="tracking-widest">
                {entry.scores.map((score) =>
                  getEmojiFromScore(
                    score,
                    resolvedTheme === "dark",
                    game.state.enableHighContrast
                  )
                )}
              </div>
            </div>
          ))}
        </div>
      ) : (
        <div className="text-center">
          <div>Menunggu pemain lain terhubung...</div>
          {config.inviteKey && (
            <button
              className="block text-center color-accent w-full"
              onClick={handleShare}
            >
              Ajak pemain
            </button>
          )}
        </div>
      )}
    </div>
  );
}


================================================
FILE: postcss.config.js
================================================
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};


================================================
FILE: public/ads.txt
================================================
google.com, pub-3081263972680635, DIRECT, f08c47fec0942fa0

================================================
FILE: public/google563f40b6a67a6d75.html
================================================
google-site-verification: google563f40b6a67a6d75.html


================================================
FILE: sentry.client.config.js
================================================
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/

import * as Sentry from "@sentry/nextjs";

const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;

Sentry.init({
  dsn: SENTRY_DSN,
  // Adjust this value in production, or use tracesSampler for greater control
  tracesSampleRate: 0.2,
  // ...
  // Note: if you want to override the automatic release value, do not set a
  // `release` value here - use the environment variable `SENTRY_RELEASE`, so
  // that it will also get attached to your source maps
});


================================================
FILE: sentry.properties
================================================
defaults.url=https://sentry.io/
defaults.org=pveyes
defaults.project=katla
cli.executable=../../.npm/_npx/26968/lib/node_modules/@sentry/wizard/node_modules/@sentry/cli/bin/sentry-cli


================================================
FILE: sentry.server.config.js
================================================
import * as Sentry from "@sentry/nextjs";

const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;

Sentry.init({
  dsn: SENTRY_DSN,
  // Adjust this value in production, or use tracesSampler for greater control
  tracesSampleRate: 0.1,
  // ...
  // Note: if you want to override the automatic release value, do not set a
  // `release` value here - use the environment variable `SENTRY_RELEASE`, so
  // that it will also get attached to your source maps
});


================================================
FILE: styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply dark:bg-gray-900 bg-white min-h-screen max-h-screen;
  min-height: -webkit-fill-available;
  max-height: -webkit-fill-available;
}

.absolute-center {
  position: absolute;
  top: 15%;
  left: 50%;
  transform: translateX(-50%) translateY(-15%);
  z-index: 10;
}

.user-score:first-child {
  margin-left: auto;
}

.user-score:last-child {
  margin-right: auto;
}

:root {
  --k-bg-accent: #15803d;
  --k-bg-accent-hover: #16a34a;
  --k-bg-correct: #15803d;
  --k-bg-exist: #ca8a04;
}

:root[data-katla-hc="true"] {
  --k-bg-accent: #f5793a;
  --k-bg-accent-hover: #f6a248;
  --k-bg-correct: #f5793a;
  --k-bg-exist: #85c0f9;
}

.bg-correct {
  background-color: var(--k-bg-correct);
}

.bg-exist {
  background-color: var(--k-bg-exist);
}

.bg-accent {
  background-color: var(--k-bg-accent);
}

.bg-ig {
  background: #f09433;
  background: linear-gradient(
    45deg,
    #f09433 0%,
    #e6683c 25%,
    #dc2743 50%,
    #cc2366 75%,
    #bc1888 100%
  );
}

.color-accent {
  color: var(--k-bg-accent);
}

.color-accent:hover {
  color: var(--k-bg-accent-hover);
}

.text-dynamic {
  font-size: clamp(0.5rem, min(3vh, 3vw), 1.5rem);
}

@media (min-height: 550px) {
  .text-dynamic {
    font-size: clamp(1rem, min(5vh, 5vw), 2.25rem);
  }
}

@keyframes bounce {
  0%,
  20% {
    transform: translateY(0);
  }

  40% {
    transform: translateY(-30px);
  }

  50% {
    transform: translateY(5px);
  }

  60% {
    transform: translateY(-15px);
  }

  80% {
    transform: translateY(2px);
  }

  100% {
    transform: translateY(0);
  }
}

@keyframes shake {
  10%,
  90% {
    transform: translateX(-1px);
  }

  20%,
  80% {
    transform: translateX(2px);
  }

  30%,
  50%,
  70% {
    transform: translateX(-4px);
  }

  40%,
  60% {
    transform: translateX(4px);
  }
}

@keyframes flip {
  0% {
    transform: rotateX(0);
  }
  50% {
    transform: rotateX(-90deg);
  }
  100% {
    transform: rotateX(0);
  }
}

@keyframes slide-down {
  0% {
    opacity: 0;
    transform: translateY(-10px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

.slide-down[data-reach-menu-list],
.slide-down[data-reach-menu-items] {
  left: -16px;
  animation: slide-down 0.2s ease;
}


================================================
FILE: tailwind.config.js
================================================
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  darkMode: "class",
  theme: {
    extend: {
      gridTemplateColumns: {
        leaderboard: "90px 1fr",
      },
    },
    borderWidth: {
      DEFAULT: "1px",
      0: "0px",
      2: "2px",
      3: "3px",
    },
  },
  plugins: [],
};


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "incremental": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}


================================================
FILE: utils/__tests__/answer.test.js
================================================
import { getAnswerStates } from "../game";

test("should mark `c` characters", () => {
  expect(getAnswerStates("SEMUA", "BENUA")).toEqual(["w", "c", "w", "c", "c"]);
});

test("should prioritize `c` before `e` and `w` characters", () => {
  expect(getAnswerStates("BABAT", "BENUA")).toEqual(["c", "e", "w", "w", "w"]);

  expect(getAnswerStates("GAGAP", "GANAR")).toEqual(["c", "c", "w", "c", "w"]);

  expect(getAnswerStates("NANAR", "GANAR")).toEqual(["w", "c", "c", "c", "c"]);
});


================================================
FILE: utils/__tests__/game.test.js
================================================
/**
 * @jest-environment jsdom
 */
jest.useFakeTimers();

import { renderHook } from "@testing-library/react-hooks";

import { useGame } from "../game";
import { GAME_STATE_KEY, INVALID_WORDS_KEY, LAST_HASH_KEY } from "../constants";
import { decode, encode, encodeHashed } from "../codec";
import LocalStorage from "../browser";

class LocalStorageMock {
  constructor() {
    this.store = {};
  }

  clear() {
    this.store = {};
  }

  getItem(key) {
    return this.store[key] || null;
  }

  setItem(key, value) {
    this.store[key] = String(value);
  }

  removeItem(key) {
    delete this.store[key];
  }
}

beforeAll(() => {
  global.localStorage = new LocalStorageMock();
});

afterEach(() => {
  localStorage.clear();
});

const num = 20;
const hashed = encodeHashed(num, "latest", "previous");

test("first time playing, ready for new game", () => {
  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());

  const { result } = renderHook(() => useGame(hashed));
  expect(decode(result.current.hash)).toBe("latest");
  expect(result.current.num).toBe(num);
  expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);
});

test("first time, not ready for new game", () => {
  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());

  const { result } = renderHook(() => useGame(hashed));
  expect(decode(result.current.hash)).toBe("previous");
  expect(result.current.num).toBe(num - 1);
  expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);
});

test("already played, ready for new game", () => {
  const answers = ["ganar", "pakar", "syair"];
  const attempt = 3;
  const lastCompletedDate = Date.now();
  const enableHardMode = true;
  const enableHighContrast = true;

  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
  localStorage.setItem(LAST_HASH_KEY, encode("previous"));
  localStorage.setItem(INVALID_WORDS_KEY, JSON.stringify(["fucek"]));
  localStorage.setItem(
    GAME_STATE_KEY,
    JSON.stringify({
      answers,
      attempt,
      lastCompletedDate,
      enableHardMode,
      enableHighContrast,
    })
  );

  const { result } = renderHook(() => useGame(hashed));
  expect(decode(result.current.hash)).toBe("latest");
  expect(result.current.num).toBe(20);
  expect(result.current.state.answers).toEqual(Array(6).fill(""));
  expect(result.current.state.attempt).toBe(0);

  // keep other state
  expect(result.current.state.lastCompletedDate).toBe(lastCompletedDate);
  expect(result.current.state.enableHardMode).toBe(enableHardMode);
  expect(result.current.state.enableHighContrast).toBe(enableHighContrast);

  // reset invalid word list
  expect(localStorage.getItem(INVALID_WORDS_KEY)).toBe("[]");
});

test("already played, not ready for new game", () => {
  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());
  localStorage.setItem(LAST_HASH_KEY, encode("previous"));

  const { result } = renderHook(() => useGame(hashed));
  expect(decode(result.current.hash)).toBe("previous");
  expect(result.current.num).toBe(num - 1);
});

test("already played, but new hash already generated", () => {
  const answers = ["ganar", "pakar", "syair"];
  const attempt = 3;
  const lastCompletedDate = Date.now();
  const enableHardMode = true;
  const enableHighContrast = true;

  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());
  localStorage.setItem(
    GAME_STATE_KEY,
    JSON.stringify({
      answers,
      attempt,
      lastCompletedDate,
      enableHardMode,
      enableHighContrast,
    })
  );

  // played on date X, now on date Y
  // but hashed already on date Y and Z
  localStorage.setItem(LAST_HASH_KEY, encode("before"));

  const { result } = renderHook(() => useGame(hashed));
  expect(decode(result.current.hash)).toBe("previous");
  expect(result.current.num).toBe(19);
  expect(result.current.state.answers).toEqual(Array(6).fill(""));

  expect(localStorage.getItem(LAST_HASH_KEY)).toBe(encode("previous"));
});

test("currently playing, should not reset state", async () => {
  const answers = ["ganar", "pakar", "syair"];
  const attempt = 3;

  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
  localStorage.setItem(LAST_HASH_KEY, encode("latest"));
  localStorage.setItem(
    GAME_STATE_KEY,
    JSON.stringify({
      answers,
      attempt,
    })
  );

  const { result } = renderHook(() => useGame(hashed));
  expect(result.current.ready).toBe(true);
  expect(decode(result.current.hash)).toBe("latest");
  expect(result.current.num).toBe(20);
  expect(result.current.state.answers).toEqual(answers);
  expect(result.current.state.attempt).toBe(attempt);
});

test("already played, refresh event", async () => {
  const answers = ["ganar", "pakar", "syair"];
  const attempt = 3;

  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
  localStorage.setItem(LAST_HASH_KEY, encode("latest"));
  localStorage.setItem(
    GAME_STATE_KEY,
    JSON.stringify({
      answers,
      attempt,
    })
  );

  let currentHashed = hashed;
  const { result, rerender } = renderHook(() => useGame(currentHashed));
  expect(decode(result.current.hash)).toBe("latest");
  expect(result.current.num).toBe(20);

  // refresh
  const newNum = 21;
  jest.setSystemTime(new Date(2022, 1, 10, 0, 0, 0).getTime());
  currentHashed = encodeHashed(newNum, "refresh", "latest");
  rerender();
  expect(decode(result.current.hash)).toBe("refresh");
  expect(result.current.num).toBe(newNum);
});


================================================
FILE: utils/animation.ts
================================================
import showConfetti from "canvas-confetti";

export default function confetti(duration: number = 3) {
  let animationFrame: any;
  const end = Date.now() + duration * 1000;

  function frame() {
    // launch a few confetti from the left edge
    showConfetti({
      particleCount: 7,
      angle: 60,
      spread: 55,
      origin: { x: 0 },
    });
    // and launch a few from the right edge
    showConfetti({
      particleCount: 7,
      angle: 120,
      spread: 55,
      origin: { x: 1 },
    });

    // keep going until we are out of time
    if (Date.now() < end) {
      animationFrame = requestAnimationFrame(frame);
    }
  }

  frame();
  return () => cancelAnimationFrame(animationFrame);
}


================================================
FILE: utils/answers.ts
================================================
import fs from "fs/promises";
import path from "path";

export function getAllAnswers() {
  return fs
    .readFile(path.join(process.cwd(), "./.scripts/answers.csv"), "utf8")
    .then((text) =>
      text
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean)
    );
}


================================================
FILE: utils/browser.ts
================================================
import Alert from "../components/Alert";

export function checkNativeShareSupport() {
  const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
  const isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1;

  if (isFirefox && isAndroid) {
    return false;
  }

  const isDesktop =
    window.screenX === 0 &&
    !("ontouchstart" in window) &&
    // https://sentry.io/share/issue/5faab0d5e08a4d02a32cace759e7e3d8/
    (screen?.orientation?.type ?? "landscape-primary") === "landscape-primary";

  if (isDesktop) {
    return false;
  }

  return "share" in navigator;
}

/**
 * Safe & drop-in replacement for localStorage access for browser with storage disabled
 *  - Firefox with dom.storage.enabled = false
 *  - Webview with storage disabled
 */
const LocalStorage: typeof window.localStorage = {
  getItem(key: string): string | null {
    try {
      const value = window.localStorage.getItem(key);
      if (value === "undefined" || !value) {
        return null;
      }
      return value;
    } catch (err) {
      return null;
    }
  },
  setItem(key: string, value: string): void {
    try {
      return window.localStorage.setItem(key, value);
    } catch (err) {
      return;
    }
  },
  removeItem(key: string): void {
    try {
      return window.localStorage.removeItem(key);
    } catch (err) {
      return;
    }
  },
  clear(): void {
    try {
      return window.localStorage.clear();
    } catch (err) {
      return;
    }
  },
  key(index: number): string | null {
    try {
      return window.localStorage.key(index);
    } catch (err) {
      return null;
    }
  },
  get length(): number {
    try {
      return window.localStorage.length;
    } catch (err) {
      return 0;
    }
  },
};

export default LocalStorage;

export function isStorageEnabled() {
  const randomKey = Math.random().toFixed(5);
  const randomValue = Math.random().toFixed(5);
  const storageKey = `katla:test:${randomKey}`;
  LocalStorage.setItem(storageKey, randomValue);
  const stored = LocalStorage.getItem(storageKey);
  if (stored === null) {
    return false;
  }

  LocalStorage.removeItem(storageKey);
  return stored === randomValue;
}

interface ShareOptions {
  cb?: () => void;
  fallbackText?: string;
  clipboardSuccessMessage?: string;
}

export function shareLink(url: string, options: ShareOptions) {
  share({ url }, { ...options, fallbackText: url });
}

export function shareText(text: string, options: ShareOptions) {
  share({ text }, { ...options, fallbackText: text });
}

export function share(data: ShareData, options: ShareOptions) {
  const useNativeShare = checkNativeShareSupport();
  const clipboardSuccessCallback = () => {
    const message = options.clipboardSuccessMessage ?? "Disalin ke clipboard";
    options.cb?.();
    Alert.show(message, { id: "clipboard " });
  };
  const clipboardFailedCallback = (_: Error) => {
    options.cb?.();
    Alert.show("Gagal menyalin ke clipboard", { id: "clipboard" });
  };

  if (useNativeShare) {
    // native share
    navigator.share(data).catch(() => {
      // TODO: handle non abort error
    });
  } else if (
    "clipboard" in navigator &&
    typeof navigator.clipboard.writeText === "function" &&
    // https://sentry.io/share/issue/5074ad1fa6b34a2a9985edc7155967f0/
    // https://stackoverflow.com/questions/61243646/clipboard-api-call-throws-notallowederror-without-invoking-onpermissionrequest
    "permissions" in navigator
  ) {
    // async clipboard API
    const promise = navigator.clipboard.writeText(options.fallbackText);

    // https://sentry.io/share/issue/59a42dfd516a439a99f763ee276aff26/
    if (promise) {
      promise.then(clipboardSuccessCallback).catch(clipboardFailedCallback);
    }
  } else {
    // legacy browsers without async clipboard API support
    const textarea = document.createElement("textarea");
    textarea.textContent = options.fallbackText;
    textarea.style.position = "fixed";
    document.body.appendChild(textarea);
    // https://sentry.io/share/issue/cb8a0ca8f6fc47858eafe4bc5959debd/
    textarea.focus();
    textarea.select();
    try {
      document.execCommand("copy");
      clipboardSuccessCallback();
    } catch (err) {
      clipboardFailedCallback(err);
    } finally {
      document.body.removeChild(textarea);
    }
  }
}


================================================
FILE: utils/codec.ts
================================================
export function encode(word: string): string {
  const base64 = Buffer.from(word).toString("base64");
  const equalSigns = base64.split("").filter((char) => char === "=").length;
  const withoutEq = base64.replace(/=/g, "");
  let newStr = "";
  for (let i = 0; i < withoutEq.length; i++) {
    newStr += String.fromCharCode(
      withoutEq.charCodeAt(i) + (i % 2 === 0 ? 1 : -1)
    );
  }

  return newStr + equalSigns;
}

export function decode(hash: string): string {
  const [equalSigns, ...chars] = hash.split("").reverse();
  const padding = "=".repeat(Number(equalSigns));
  const base64 =
    chars
      .reverse()
      .map((str, i) => {
        const charCode = str.charCodeAt(0) + (i % 2 === 0 ? -1 : 1);
        return String.fromCharCode(charCode);
      })
      .join("") + padding;
  return Buffer.from(base64, "base64").toString();
}

const HASHED_SEPARATOR = "::";

export function encodeHashed(
  num: number,
  latestAnswer: string,
  previousAnswer: string
) {
  return encode(
    [num, encode(latestAnswer), encode(previousAnswer)].join(HASHED_SEPARATOR)
  );
}

export function decodeHashed(hashed: string) {
  return decode(hashed).split(HASHED_SEPARATOR);
}


================================================
FILE: utils/constants.ts
================================================
export const LAST_HASH_KEY = "katla:lastHash";
export const LAST_SESSION_RESET_KEY = "katla:lastSessionReset";
export const GAME_LIVE_STATE_KEY = "katla:liveGameState";
export const GAME_STATE_KEY = "katla:gameState";
export const GAME_STATS_KEY = "katla:gameStats";
export const INVALID_WORDS_KEY = "katla:invalidWords";

// animation
export const SHAKE_ANIMATION_DURATION_MS = 600;
export const FLIP_ANIMATION_DURATION_MS = 800;
export const FLIP_ANIMATION_DELAY_MS = 400;


================================================
FILE: utils/fetcher.ts
================================================
const fetcher = (...args: Parameters<typeof fetch>) =>
  fetch(...args).then((res) => res.json());

export default fetcher;


================================================
FILE: utils/formatter.ts
================================================
export function formatDate(d: Date) {
  const year = d.getFullYear();
  const month = d.getMonth() + 1;
  const date = d.getDate();
  return `${year}-${pad0(month)}-${pad0(date)}`;
}

export function formatTime(d: Date) {
  const hours = d.getHours();
  const minutes = d.getMinutes();
  const seconds = d.getSeconds();
  return `${hours}:${pad0(minutes)}:${pad0(seconds)}`;
}

export function pad0(n: number): string {
  return n.toString().padStart(2, "0");
}


================================================
FILE: utils/game.ts
================================================
import { useEffect, useState } from "react";
import { useRouter } from "next/router";

import { LAST_HASH_KEY, GAME_STATE_KEY, INVALID_WORDS_KEY } from "./constants";
import {
  AnswerState,
  Game,
  GameState,
  GameStats,
  MigrationData,
} from "./types";
import LocalStorage, { isStorageEnabled } from "./browser";
import createStoredState from "./useStoredState";
import { trackEvent } from "./tracking";
import { decode, decodeHashed } from "./codec";
import { unstable_batchedUpdates } from "react-dom";

export const initialState: GameState = {
  answers: Array(6).fill(""),
  attempt: 0,
  lastCompletedDate: null,
  enableHighContrast: false,
  enableHardMode: false,
  enableFreeEdit: false,
  enableLiarMode: false,
  lieBoxes: [],
};

export const useGamePersistedState =
  createStoredState<GameState>(GAME_STATE_KEY);

export function useGame(hashed: string, enableStorage: boolean = true): Game {
  const useGameState = enableStorage ? useGamePersistedState : useState;
  const [state, setState] = useGameState<GameState>(initialState);
  const [readyState, setGameReadyState] = useState<Game["readyState"]>("init");
  const router = useRouter();

  const [num, latestHash, previousHash] = decodeHashed(hashed);
  const initialCurrentNum = Number(num);
  const [currentNum, setCurrentNum] = useState(initialCurrentNum);
  const [currentHash, setCurrentHash] = useState(latestHash);

  useEffect(() => {
    if (!enableStorage) {
      setGameReadyState("ready");
      return;
    }

    if (isStorageEnabled()) {
      setGameReadyState("ready");
    } else {
      setGameReadyState("no-storage");
      return;
    }

    // check for new game schedule
    const now = new Date();
    const gameDate = new Date("2022-01-20");
    gameDate.setDate(gameDate.getDate() + initialCurrentNum);
    gameDate.setHours(0);
    gameDate.setMinutes(0);
    gameDate.setSeconds(0);
    gameDate.setMilliseconds(0);
    const isAfterGameDate = now.getTime() >= gameDate.getTime();

    const lastHash = LocalStorage.getItem(LAST_HASH_KEY);

    // first time playing
    if (!lastHash) {
      if (!isAfterGameDate) {
        unstable_batchedUpdates(() => {
          setCurrentHash(previousHash);
          setCurrentNum(initialCurrentNum - 1);
        });
        LocalStorage.setItem(LAST_HASH_KEY, previousHash);
        return;
      }

      LocalStorage.setItem(LAST_HASH_KEY, currentHash);
      return;
    }

    // already play
    if (lastHash !== latestHash) {
      // ready for a new game
      if (isAfterGameDate) {
        unstable_batchedUpdates(() => {
          setCurrentHash(latestHash);
          setCurrentNum(initialCurrentNum);
          setState((state) => ({
            ...state,
            answers: Array(6).fill(""),
            attempt: 0,
            // always reset liar mode
            enableLiarMode: false,
            lieBoxes: [],
          }));
        });
        LocalStorage.setItem(LAST_HASH_KEY, latestHash);
        LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));
      } else if (lastHash !== previousHash) {
        // last hash is not in hashed
        unstable_batchedUpdates(() => {
          setCurrentHash(previousHash);
          setCurrentNum(initialCurrentNum - 1);
          setState((state) => ({
            ...state,
            answers: Array(6).fill(""),
            attempt: 0,
            // always reset liar mode
            enableLiarMode: false,
            lieBoxes: [],
          }));
        });
        LocalStorage.setItem(LAST_HASH_KEY, previousHash);
        LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));
      } else {
        unstable_batchedUpdates(() => {
          setCurrentHash(previousHash);
          setCurrentNum(initialCurrentNum - 1);
        });
      }
    }
    // we want this effect to execute only once on mount
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [hashed]);

  function migrate(hash: string, state: GameState) {
    unstable_batchedUpdates(() => {
      setCurrentHash(hash);
      setState(state);
    });
  }

  function trackInvalidWord(word: string) {
    let invalidWords = [];
    try {
      invalidWords = JSON.parse(localStorage.getItem(INVALID_WORDS_KEY));
      if (!Array.isArray(invalidWords)) {
        throw new Error("invalid words is not an array");
      }
    } catch (err) {
      invalidWords = [];
    }

    if (invalidWords.includes(word)) {
      return;
    }

    invalidWords.push(word);
    LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify(invalidWords));
    trackEvent("invalid_word", { word });
  }

  useEffect(() => {
    if (state.enableHighContrast) {
      document.documentElement.setAttribute("data-katla-hc", "true");
    } else {
      document.documentElement.removeAttribute("data-katla-hc");
    }
  }, [state.enableHighContrast]);

  useEffect(() => {
    function handleVisibilityChange() {
      if (document.visibilityState === "visible") {
        router.replace(router.asPath);
      }
    }

    document.addEventListener("visibilitychange", handleVisibilityChange);
    return () =>
      document.removeEventListener("visibilitychange", handleVisibilityChange);
  }, [router]);

  return {
    hash: currentHash,
    num: currentNum,
    migrate,
    readyState,
    ready: readyState !== "init",
    state,
    setState,
    trackInvalidWord,
  };
}

export function useRemainingTime() {
  const now = new Date();
  const hours = getHoursDiff(now);
  const minutes = getMinutesDiff(now);
  const seconds = getSecondsDiff(now);
  const router = useRouter();

  const [remainingTime, setRemainingTime] = useState({
    hours,
    minutes,
    seconds,
  });

  useEffect(() => {
    const t = setInterval(() => {
      const now = new Date();
      const hours = getHoursDiff(now);
      const minutes = getMinutesDiff(now);
      const seconds = getSecondsDiff(now);

      if (hours + minutes + seconds === 0) {
        router.replace(router.asPath);
      }

      setRemainingTime({ hours, minutes, seconds });
    }, 100);
    return () => clearInterval(t);
  }, [router]);

  return remainingTime;
}

function getHoursDiff(date: Date) {
  return date.getHours() === 0 &&
    date.getMinutes() === 0 &&
    date.getSeconds() === 0
    ? 0
    : 23 - date.getHours();
}

function getMinutesDiff(date: Date) {
  return date.getMinutes() === 0
    ? 0
    : (date.getSeconds() === 0 ? 60 : 59) - date.getMinutes();
}

function getSecondsDiff(date: Date) {
  return date.getSeconds() === 0 ? 0 : 60 - date.getSeconds();
}

export function isGameFinished(game: Game) {
  return (
    game.state.attempt === 6 ||
    game.state.answers[game.state.attempt - 1] === decode(game.hash)
  );
}

export function getTotalWin(stats: GameStats) {
  const { fail, ...wins } = stats.distribution;
  const totalWin = Object.values(wins).reduce((a, b) => a + b, 0);
  return totalWin;
}

export function getTotalPlay(stats: GameStats) {
  return getTotalWin(stats) + stats.distribution.fail;
}

export function verifyStreak(lastCompletedDate: number | null): boolean {
  if (!lastCompletedDate) {
    return true;
  }

  const lastDate = new Date(lastCompletedDate);
  const now = new Date();
  now.setDate(now.getDate() - 1);
  return (
    now.getDate() === lastDate.getDate() &&
    now.getMonth() === lastDate.getMonth() &&
    now.getFullYear() === lastDate.getFullYear()
  );
}

type AnswerStates = [
  AnswerState,
  AnswerState,
  AnswerState,
  AnswerState,
  AnswerState
];

export function getAnswerStates(
  userAnswer: string,
  answer: string
): AnswerStates {
  const states: AnswerStates = Array(5).fill(null) as any;

  const answerChars = answer.split("");
  const userAnswerChars = userAnswer.split("");
  for (let i = 0; i < answerChars.length; i++) {
    if (userAnswer[i] === answerChars[i]) {
      states[i] = "c";
      answerChars[i] = null;
      userAnswerChars[i] = null;
    }
  }

  for (let i = 0; i < userAnswerChars.length; i++) {
    if (userAnswerChars[i] === null) {
      continue;
    }

    const answerIndex = answerChars.indexOf(userAnswer[i]);
    if (answerIndex !== -1) {
      states[i] = "e";
      answerChars[answerIndex] = null;
      userAnswerChars[i] = null;
    }
  }

  return states.map((s) => (s === null ? "w" : s)) as any;
}

export function checkHardModeAnswer(
  state: GameState,
  answer: string
): [isInvalid: boolean, unusedChar: string, letterIndex?: number] {
  const previousAnswer = state.answers[state.attempt - 1];
  const currentAnswer = state.answers[state.attempt];

  const previousAnswerStates = getAnswerStates(previousAnswer, answer);
  const currentAnswerStates = getAnswerStates(currentAnswer, answer);

  // first check for unused characters
  const mustBeUsedChars: string[] = previousAnswerStates.flatMap((state, i) => {
    if (state === "e") {
      return previousAnswer[i];
    }
    return [];
  });

  for (let i = 0; i < mustBeUsedChars.length; i++) {
    if (!currentAnswer.includes(mustBeUsedChars[i])) {
      return [true, mustBeUsedChars[i].toUpperCase()];
    }
  }

  // then check for matching answer
  for (let i = 0; i < previousAnswerStates.length; i++) {
    if (previousAnswerStates[i] === "c" && currentAnswerStates[i] !== "c") {
      return [true, previousAnswer[i].toUpperCase(), i + 1];
    }
  }

  return [false, ""];
}

export function generateMigrationLink(hash: string, stats: GameStats): string {
  const migrationData: MigrationData = {
    stats,
    lastHash: hash,
    time: Date.now(),
  };
  const encodedMigrationData = encodeURIComponent(
    JSON.stringify(migrationData)
  );

  return `https://katla.id/?migrate=${encodedMigrationData}`;
}


================================================
FILE: utils/liveGame.ts
================================================
import { useEffect, useState } from "react";
import {
  useBroadcastEvent,
  useEventListener,
  useList,
  useOthers,
  useRoom,
  useSelf,
  useUpdateMyPresence,
} from "@liveblocks/react";

import { Game, GameState, LiveConfig, LiveEvent } from "./types";
import { GAME_LIVE_STATE_KEY } from "./constants";
import createStoredState from "./useStoredState";
import LocalStorage, { isStorageEnabled, shareLink } from "./browser";
import { getAnswerStates, initialState as gameInitialState } from "./game";
import { decode, encode } from "./codec";
import { rainEmoji } from "../components/EmojiRain";
import confetti from "./animation";
import Alert from "../components/Alert";

export interface LiveGameState extends GameState {
  winCount: number;
}

export interface LiveGame extends Game<LiveGameState> {
  start: () => void;
}

const initialState: LiveGameState = {
  ...gameInitialState,
  winCount: 0,
};

const useGameState = createStoredState<LiveGameState>(GAME_LIVE_STATE_KEY);
export const defaultScore = Array(5).fill(0);

const NEW_GAME_DELAY_MS = 5000;

export function useLiveGame(words: string[]): LiveGame {
  const [state, setState] = useGameState(initialState);
  const [readyState, setGameReadyState] = useState<Game["readyState"]>("init");
  const hashes = useList<string>("hashes", []);
  const [num, setNum] = useState(-1);
  const [hash, setHash] = useState("");
  const updateMyPresence = useUpdateMyPresence();
  const broadcast = useBroadcastEvent();
  const self = useSelf();
  const others = useOthers();
  const room = useRoom();

  useEffect(() => {
    if (isStorageEnabled()) {
      setGameReadyState("ready");
    } else {
      setGameReadyState("no-storage");
      return;
    }
  }, []);

  useEventListener(({ event }: { event: LiveEvent }) => {
    switch (event.type) {
      case "emoji": {
        Alert.show(`Pesan dari: ${event.username}`, { id: "emoji" });
        rainEmoji(event.emoji);
        break;
      }
      case "win": {
        handleWin(event.username);
        break;
      }
      case "lose": {
        handleLose(event.answer);
        break;
      }
      case "start": {
        handleStart();
        break;
      }
    }
  });

  function submitAnswer(answer: string, attempt: number) {
    const scores = getUserScores(answer, hash);

    const totalScore = getTotalScore(scores);
    if (totalScore === 5) {
      room.batch(() => {
        handleWin(self.id);
        broadcast({ type: "win", username: self.id });
        updateMyPresence({ winCount: (self.presence?.winCount ?? 0) + 1 });
      });

      setTimeout(() => {
        room.batch(() => {
          startNewGame();
          handleStart();
        });
      }, NEW_GAME_DELAY_MS);
    }

    const isFailed = attempt === 6 && totalScore !== 5;
    if (isFailed && others.toArray().every((user) => user.presence?.isFailed)) {
      const answer = decode(hash);
      room.batch(() => {
        handleLose(answer);
        broadcast({ type: "lose", answer });
      });

      setTimeout(() => {
        room.batch(() => {
          startNewGame();
          handleStart();
        });
      }, NEW_GAME_DELAY_MS);
    }

    updateMyPresence({ scores, isFailed });
  }

  function handleWin(username: string) {
    confetti();
    Alert.show(
      `Selamat, ${username}!!\nRonde selanjutnya akan dimulai dalam 5 detik`,
      { id: "answer", duration: NEW_GAME_DELAY_MS }
    );
  }

  function handleLose(answer: string) {
    rainEmoji("💀");
    Alert.show(
      `Jawaban: ${answer}.\nRonde selanjutnya akan dimulai dalam 5 detik`,
      { id: "answer", duration: NEW_GAME_DELAY_MS }
    );
  }

  function handleStart() {
    updateMyPresence({ scores: defaultScore, isFailed: false });
    setState((state) => ({
      ...initialState,
      winCount: state.winCount,
    }));
  }

  function startNewGame() {
    const unusedWords = words.filter(
      (word) => !hashes.find((hash) => hash === encode(word))
    );
    const word = unusedWords[Math.floor(Math.random() * unusedWords.length)];

    hashes.push(encode(word));
    broadcast({ type: "start" });
  }

  function resetState() {
    room.batch(() => {
      hashes.clear();
      startNewGame();
      handleStart();
    });
  }

  useEffect(() => {
    if (!hashes) {
      return;
    }

    function handleSubscribe() {
      const hash = hashes.get(hashes.length - 1);
      setNum(hashes.length);
      setHash(hash);
    }

    handleSubscribe();
    const unsubscribe = room.subscribe(hashes, handleSubscribe);
    return () => unsubscribe();
  }, [hashes, room]);

  function start() {
    room.batch(() => {
      startNewGame();
      handleStart();
    });
  }

  useEffect(() => {
    if (!hash) {
      return;
    }

    try {
      const storedState: LiveGameState = JSON.parse(
        LocalStorage.getItem(GAME_LIVE_STATE_KEY)
      );
      if (storedState.attempt > 0 && !self.presence?.scores) {
        const scores = getUserScores(
          storedState.answers[storedState.attempt - 1],
          hash
        );
        const totalScore = getTotalScore(scores);
        const isFailed = storedState.attempt === 6 && totalScore !== 5;
        updateMyPresence({ scores, isFailed, winCount: storedState.winCount });
      }
    } catch (_) {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return {
    hash,
    num,
    migrate: () => {},
    ready: readyState === "ready" && !!hashes,
    readyState,
    state,
    setState,
    trackInvalidWord: () => {},
    submitAnswer,
    resetState,
    start,
  };
}

export function generateRoomId(auth: string) {
  const id = "xxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g, () => {
    return ((Math.random() * 16) | 0).toString(16);
  });

  return "kt-" + auth + "-" + id;
}

type Scores = Array<number>;

function getUserScores(answer: string, hash: string): Scores {
  return getAnswerStates(answer, decode(hash)).map((state) => {
    switch (state) {
      case "c":
        return 1;
      case "e":
        return 0.5;
      case "w":
        return 0;
    }
  });
}

export function getTotalScore(scores: Scores) {
  return scores.reduce((sum, state) => sum + state, 0);
}

export function getEmojiFromScore(
  score: number,
  darkMode: boolean,
  highContrast: boolean
) {
  switch (score) {
    case 0:
      return darkMode ? "⬜️" : "⬛";
    case 1:
      return highContrast ? "🟧" : "🟩";
    case 0.5:
      return highContrast ? "🟦" : "🟨";
  }
}

export function shareInviteLink(config: LiveConfig, cb?: () => void) {
  const host = window.location.protocol + "//" + window.location.host;
  const url = `${host}/lawan?room=${config.roomId}&invite=${config.inviteKey}`;
  shareLink(url, {
    cb,
    clipboardSuccessMessage: "Tautan telah disalin ke clipboard",
  });
}


================================================
FILE: utils/message.ts
================================================
import { getTotalPlay } from "./game";
import { Game, AnswerState, GameStats } from "./types";
import confetti from "./animation";
import { encode } from "./codec";
import { rainEmoji } from "../components/EmojiRain";
import Alert from "../components/Alert";

export function getCongratulationMessage(attempt: number, stats: GameStats) {
  const totalPlay = getTotalPlay(stats);
  const { fail, ...rest } = stats.distribution;

  if (totalPlay === 0 && attempt === 1) {
    return randomElement(["Browser baru?", "Baru main? 👏👏"]);
  }

  const message1 = [
    "Hoki? Atau kena spoiler",
    "Wow",
    "Jenius",
    "Ajaib",
    "Cenayang",
    "Si Peramal",
    "Penjelajah Waktu",
  ];
  const message2 = [
    "Kebanggaan Negara",
    "Kesayangan Ibu Pertiwi",
    "Sakti Mandraguna",
    "Cendekiawan",
    "Kaum Intelek",
  ];
  const message3 = [
    "Luar Biasa!",
    "Jagoan!",
    "Mantap!",
    "Cerdas!",
    "Keren!",
    "Salut!",
    "Hebat!",
    "Lantip!",
    "Brilian!",
    "Otak Cemerlang",
  ];
  const message4 = [
    "Bagus Sekali",
    "Cermat",
    "Pintar",
    "Teladan",
    "Idaman",
    "Cerdik",
    "Encer",
  ];
  const message5 = ["Bagus", "Horee", "Selamat!", "Pandai"];
  const message6 = ["Nyaris!!!", "Hampir saja", "Lega!!"];

  if (stats.distribution[6] + fail > totalPlay / 3) {
    message6.push("Hobi amat mepet", "Suka angka 6?");
  }

  if (totalPlay > 7 && fail > totalPlay / 3) {
    message6.push("Hampir dideportasi");
  }

  if (totalPlay > 7) {
    message4.push("4 Sehat", "Dikit lagi Keren!");
    message5.push("5 sempurna", "Tidak buruk", "Okelah");
    message6.push("Mepet!");

    if (stats.distribution[6] > 3) {
      message6.push("Tetap semangat!", "Mungkin besok lebih baik");
    }

    if (stats.distribution[attempt] === 0) {
      const bestMove = Object.values(rest).findIndex((value) => value > 0) + 1;
      if (attempt < bestMove && stats.distribution[bestMove] > 3) {
        return "Akhirnyaaa 🥳";
      }
    }
  }

  switch (attempt) {
    case 1:
      return randomElement(message1);
    case 2:
      return randomElement(message2);
    case 3:
      return randomElement(message3);
    case 4:
      return randomElement(message4);
    case 5:
      return randomElement(message5);
    default:
      return randomElement(message6);
  }
}

export function getFailureMessage(
  stats: GameStats,
  answerStates: AnswerState[]
) {
  const messageFail = ["Sayang sekali"];

  if (answerStates.filter((state) => state === "c").length === 4) {
    messageFail.push("Sabar ya", "Dikiit lagi 🤏", "Upss");
  }

  if (stats.distribution.fail > 1) {
    messageFail.push(
      "Jangan menyerah",
      "Coba lagi besok",
      "Masih belum beruntung"
    );
  }

  if (stats.distribution.fail > 3) {
    messageFail.push("Belajar lagi");
  }

  return randomElement(messageFail);
}

function randomElement<T>(array: T[]): T {
  const index = Math.floor(Math.random() * array.length);
  return array[index];
}

const LOVE_HASHES = [
  "Z1mteFF1",
  "b1GybVh1",
  "d1W/bVF1",
  "dl:sZV51",
  "bV6jZVh1",
  "ZmWt[1F1",
  "clmqZVh1",
  "blGtbll1",
  "eGWreWN1",
  "dlmt[GV1",
];

const EID_HASHES = [
  "cV:nc151",
  "cFGnbWJ1",
  "ZlG/bV51",
  "[lm/dll1",
  "dGWgd1F1",
  "d1GrZWR1",
  "flGqZWR1",
  "[V2ud1l1",
  "bFmrZVx1",
  "bV6yZVZ1",
  "d1GhZWJ1",
  "eGWreWN1",
];

export function handleSubmitWord(game: Game, userAnswer: string) {
  if (game.num === 25 && LOVE_HASHES.includes(encode(userAnswer))) {
    const loveEmojis = ["💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝"];
    const emoji = loveEmojis[Math.floor(Math.random() * loveEmojis.length)];
    return rainEmoji(emoji);
  }

  if (game.num === 102 && EID_HASHES.includes(encode(userAnswer))) {
    return rainEmoji("🙏");
  }
}

interface GameCompleteOptions {
  hash: string;
  attempt: number;
  stats: GameStats;
  cb?: () => void;
}

export function handleGameComplete(options: GameCompleteOptions) {
  const { hash, attempt, stats, cb } = options;
  const message = getCongratulationMessage(attempt, stats);
  Alert.show(message, {
    id: "finish",
    duration: 1250,
    cb,
  });

  if (hash === "eVy/ZVh1") {
    return confetti();
  }

  if (hash === "ZlWxZVt1" && attempt === 1) {
    return rainEmoji("💩");
  }
}

export function isEidMessage(answer: string) {
  return EID_HASHES.includes(encode(answer));
}


================================================
FILE: utils/tracking.ts
================================================
declare global {
  interface Window {
    gtag: (...args: any[]) => void;
  }
}

export function trackEvent(
  eventName: string,
  args: Record<string, string | number>
) {
  if ("gtag" in window && typeof window.gtag === "function") {
    window.gtag("event", eventName, args);
  }
}


================================================
FILE: utils/types.ts
================================================
import { Dispatch, SetStateAction } from "react";

export type AnswerState = "c" | "e" | "w";
export type ForcedResult = [column: number, state: AnswerState];

export interface GameState {
  answers: string[];
  attempt: number;
  lastCompletedDate: number | null;
  enableHighContrast: boolean;
  enableHardMode: boolean;
  enableFreeEdit: boolean;
  enableLiarMode: boolean;
  lieBoxes: ForcedResult[];
}

export interface GameStats {
  distribution: {
    1: number;
    2: number;
    3: number;
    4: number;
    5: number;
    6: number;
    fail: number;
  };
  currentStreak: number;
  maxStreak: number;
}

export interface Game<T = GameState> {
  hash: string;
  num: number;
  readyState: "init" | "no-storage" | "ready";
  ready: boolean;
  state: T;
  migrate: (lastHash: string, state: T) => void;
  setState: Dispatch<SetStateAction<T>>;
  trackInvalidWord?: (word: string) => void;
  submitAnswer?: (answer: string, attempt: number) => void;
  resetState?: () => void;
}

export interface MigrationData {
  stats: GameStats;
  lastHash: string;
  time: number;
}

export interface LiveConfig {
  isHost: boolean;
  roomId: string;
  inviteKey: string;
}

interface StartEvent {
  type: "start";
  hash: string;
  num: number;
}

interface EmojiEvent {
  type: "emoji";
  emoji: string;
  username: string;
}

interface WinEvent {
  type: "win";
  username: string;
}

interface LoseEvent {
  type: "lose";
  answer: string;
}

export type LiveEvent = StartEvent | EmojiEvent | WinEvent | LoseEvent;


================================================
FILE: utils/useStoredState.ts
================================================
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import LocalStorage from "./browser";

export default function createStoredState<T>(storageKey: string) {
  return function useStoredState(initialState: T): [T, Dispatch<SetStateAction<T>>] {
    const [state, setState] = useState<T>(initialState);

    function setStoredState(state: T) {
      LocalStorage.setItem(storageKey, JSON.stringify(state));
      setState(state);
    }

    useEffect(() => {
      function updateStateFromStorage() {
        try {
          const storedState = LocalStorage.getItem(storageKey);
          if (storedState) {
            const parsedState = JSON.parse(storedState);
            setState(parsedState);
          }
        } catch (_) {}
      }

      updateStateFromStorage();
      window.addEventListener("focus", updateStateFromStorage);
      return () => window.removeEventListener("focus", updateStateFromStorage);
    }, []);

    return [state, setStoredState];
  };
}
Download .txt
gitextract_1iyklgqu/

├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── test.yml
│       └── update.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .prettierignore
├── .scripts/
│   ├── answers.csv
│   ├── update.js
│   └── whitelist.csv
├── .vscode/
│   └── settings.json
├── README.md
├── components/
│   ├── Alert.tsx
│   ├── App.tsx
│   ├── Board.tsx
│   ├── Container.tsx
│   ├── EmojiRain.tsx
│   ├── EmojiSelector.tsx
│   ├── Header.tsx
│   ├── HeadingWithNum.tsx
│   ├── HelpModal.tsx
│   ├── Keyboard.tsx
│   ├── KeyboardButton.tsx
│   ├── Link.tsx
│   ├── LiveStatsModal.tsx
│   ├── Modal.tsx
│   ├── SettingsModal.tsx
│   ├── SponsorshipFooter.tsx
│   ├── StatsModal.tsx
│   └── Tile.tsx
├── env.dev
├── jest.config.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages/
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── _error.js
│   ├── api/
│   │   ├── define/
│   │   │   └── [word].ts
│   │   ├── live.ts
│   │   └── words.ts
│   ├── arsip/
│   │   ├── [num].tsx
│   │   └── index.tsx
│   ├── bantuan.tsx
│   ├── index.tsx
│   └── lawan.tsx
├── postcss.config.js
├── public/
│   ├── ads.txt
│   └── google563f40b6a67a6d75.html
├── sentry.client.config.js
├── sentry.properties
├── sentry.server.config.js
├── styles/
│   └── globals.css
├── tailwind.config.js
├── tsconfig.json
└── utils/
    ├── __tests__/
    │   ├── answer.test.js
    │   └── game.test.js
    ├── animation.ts
    ├── answers.ts
    ├── browser.ts
    ├── codec.ts
    ├── constants.ts
    ├── fetcher.ts
    ├── formatter.ts
    ├── game.ts
    ├── liveGame.ts
    ├── message.ts
    ├── tracking.ts
    ├── types.ts
    └── useStoredState.ts
Download .txt
SYMBOL INDEX (171 symbols across 44 files)

FILE: .scripts/update.js
  constant TIMEZONE_OFFSET (line 9) | const TIMEZONE_OFFSET = 9;
  function insertWord (line 19) | async function insertWord(answers, answer) {
  function writeCommit (line 29) | async function writeCommit(data) {
  function getMidnightDate (line 59) | function getMidnightDate() {
  function main (line 67) | async function main() {

FILE: components/Alert.tsx
  function Alert (line 3) | function Alert() {
  type AlertOptions (line 20) | interface AlertOptions {

FILE: components/App.tsx
  type Props (line 18) | interface Props {
  function App (line 29) | function App(props: Props) {

FILE: components/Board.tsx
  type Props (line 8) | interface Props {
  function Board (line 13) | function Board(props: Props) {

FILE: components/Container.tsx
  function Container (line 3) | function Container(props: PropsWithChildren<{}>) {

FILE: components/EmojiRain.tsx
  constant CUSTOM_EVENT_NAME (line 3) | const CUSTOM_EVENT_NAME = "emoji-rain";
  function EmojiRain (line 5) | function EmojiRain() {
  function rainEmoji (line 98) | function rainEmoji(emoji: string) {
  type RangeValue (line 103) | interface RangeValue {
  function interpolate (line 108) | function interpolate(current: number, from: RangeValue, to: RangeValue) {
  function randomBetween (line 122) | function randomBetween(min: number, max: number) {
  class ElementPosition (line 128) | class ElementPosition {
    method constructor (line 136) | constructor(
    method updateY (line 150) | updateY(velocity: number) {

FILE: components/EmojiSelector.tsx
  type Props (line 4) | interface Props {
  function EmojiSelector (line 8) | function EmojiSelector(props: Props) {
  function EmojiBar (line 35) | function EmojiBar({ onSelect }) {

FILE: components/Header.tsx
  type Props (line 7) | interface Props {
  function Header (line 24) | function Header(props: Props) {

FILE: components/HeadingWithNum.tsx
  type Props (line 1) | interface Props {
  function HeadingWithNum (line 6) | function HeadingWithNum(props: Props) {

FILE: components/HelpModal.tsx
  type Props (line 5) | interface Props {
  function HelpModal (line 13) | function HelpModal(props: Props) {

FILE: components/Keyboard.tsx
  type Props (line 7) | interface Props {
  function Keyboard (line 16) | function Keyboard(props: Props) {

FILE: components/KeyboardButton.tsx
  type Props (line 4) | type Props = {
  function KeyboardButton (line 9) | function KeyboardButton(props: Props) {

FILE: components/Link.tsx
  function Link (line 4) | function Link(props: ComponentProps<typeof NextLink>) {

FILE: components/LiveStatsModal.tsx
  type Props (line 5) | interface Props {
  constant GRAPH_WIDTH_MIN_RATIO (line 11) | const GRAPH_WIDTH_MIN_RATIO = 10;
  function LiveStatsModal (line 13) | function LiveStatsModal(props: Props) {

FILE: components/Modal.tsx
  type Props (line 7) | interface Props {
  function Modal (line 13) | function Modal(props: Props) {
  type ModalState (line 55) | type ModalState = "help" | "stats" | "settings";
  type ModalStateReturn (line 57) | type ModalStateReturn = [ModalState, (state: ModalState) => void, () => ...
  function useModalState (line 59) | function useModalState(game: Game, stats: GameStats): ModalStateReturn {

FILE: components/SettingsModal.tsx
  type Props (line 18) | interface Props {
  function SettingsModal (line 25) | function SettingsModal(props: Props) {
  type AdminToolsProps (line 100) | interface AdminToolsProps {
  function AdminTools (line 106) | function AdminTools(props: AdminToolsProps) {
  type PlayerToolsProps (line 131) | interface PlayerToolsProps {
  function PlayerTools (line 135) | function PlayerTools(props: PlayerToolsProps) {
  function AdditionalInformation (line 154) | function AdditionalInformation() {
  type SwitchProps (line 195) | interface SwitchProps {
  function Switch (line 205) | function Switch(props: SwitchProps) {
  function generateLieBoxes (line 263) | function generateLieBoxes(): ForcedResult[] {

FILE: components/SponsorshipFooter.tsx
  function SponsorshipFooter (line 1) | function SponsorshipFooter() {

FILE: components/StatsModal.tsx
  type Props (line 20) | interface Props {
  type Window (line 29) | interface Window {
  constant GRAPH_WIDTH_MIN_RATIO (line 34) | const GRAPH_WIDTH_MIN_RATIO = 10;
  function StatsModal (line 36) | function StatsModal(props: Props) {
  function WordDefinition (line 411) | function WordDefinition({ answer }) {
  function TimeCounter (line 453) | function TimeCounter({ time }: { time: ReturnType<typeof useRemainingTim...

FILE: components/Tile.tsx
  type Props (line 9) | interface Props {
  function Tile (line 17) | function Tile(props: Props) {

FILE: pages/_app.tsx
  function MyApp (line 8) | function MyApp({ Component, pageProps }) {

FILE: pages/_document.tsx
  function Document (line 3) | function Document() {

FILE: pages/api/define/[word].ts
  type Definition (line 5) | interface Definition {
  type KategloResponse (line 9) | interface KategloResponse {
  function handler (line 20) | async function handler(req: NextApiRequest, res: NextApiResponse) {
  function fetchFromMakna (line 74) | async function fetchFromMakna(word: string): Promise<string[]> {
  function fetchFromKbbi (line 83) | async function fetchFromKbbi(word: string): Promise<string[]> {

FILE: pages/api/live.ts
  function handler (line 10) | async function handler(

FILE: pages/api/words.ts
  function handler (line 3) | async function handler(

FILE: pages/arsip/[num].tsx
  type Props (line 20) | interface Props {
  function Arsip (line 40) | function Arsip(props: Props) {

FILE: pages/arsip/index.tsx
  function Arsip (line 13) | function Arsip({ nums }) {

FILE: pages/bantuan.tsx
  function Debug (line 11) | function Debug(props: { hashed: string }) {
  function NewSiteWarning (line 129) | function NewSiteWarning() {

FILE: pages/index.tsx
  type Props (line 28) | interface Props {
  constant VALID_STATS_DELAY_MS (line 49) | const VALID_STATS_DELAY_MS = 5000;
  function Home (line 51) | function Home(props: Props) {

FILE: pages/lawan.tsx
  type Props (line 41) | interface Props {
  function Lawan (line 45) | function Lawan({ words }: Props) {
  type MainProps (line 176) | interface MainProps {
  function Main (line 181) | function Main({ words, config }: MainProps) {
  type GameBarProps (line 266) | interface GameBarProps {
  function LiveGameBar (line 272) | function LiveGameBar(props: GameBarProps) {

FILE: sentry.client.config.js
  constant SENTRY_DSN (line 7) | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SEN...

FILE: sentry.server.config.js
  constant SENTRY_DSN (line 3) | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SEN...

FILE: utils/__tests__/game.test.js
  class LocalStorageMock (line 13) | class LocalStorageMock {
    method constructor (line 14) | constructor() {
    method clear (line 18) | clear() {
    method getItem (line 22) | getItem(key) {
    method setItem (line 26) | setItem(key, value) {
    method removeItem (line 30) | removeItem(key) {

FILE: utils/animation.ts
  function confetti (line 3) | function confetti(duration: number = 3) {

FILE: utils/answers.ts
  function getAllAnswers (line 4) | function getAllAnswers() {

FILE: utils/browser.ts
  function checkNativeShareSupport (line 3) | function checkNativeShareSupport() {
  method getItem (line 30) | getItem(key: string): string | null {
  method setItem (line 41) | setItem(key: string, value: string): void {
  method removeItem (line 48) | removeItem(key: string): void {
  method clear (line 55) | clear(): void {
  method key (line 62) | key(index: number): string | null {
  method length (line 69) | get length(): number {
  function isStorageEnabled (line 80) | function isStorageEnabled() {
  type ShareOptions (line 94) | interface ShareOptions {
  function shareLink (line 100) | function shareLink(url: string, options: ShareOptions) {
  function shareText (line 104) | function shareText(text: string, options: ShareOptions) {
  function share (line 108) | function share(data: ShareData, options: ShareOptions) {

FILE: utils/codec.ts
  function encode (line 1) | function encode(word: string): string {
  function decode (line 15) | function decode(hash: string): string {
  constant HASHED_SEPARATOR (line 29) | const HASHED_SEPARATOR = "::";
  function encodeHashed (line 31) | function encodeHashed(
  function decodeHashed (line 41) | function decodeHashed(hashed: string) {

FILE: utils/constants.ts
  constant LAST_HASH_KEY (line 1) | const LAST_HASH_KEY = "katla:lastHash";
  constant LAST_SESSION_RESET_KEY (line 2) | const LAST_SESSION_RESET_KEY = "katla:lastSessionReset";
  constant GAME_LIVE_STATE_KEY (line 3) | const GAME_LIVE_STATE_KEY = "katla:liveGameState";
  constant GAME_STATE_KEY (line 4) | const GAME_STATE_KEY = "katla:gameState";
  constant GAME_STATS_KEY (line 5) | const GAME_STATS_KEY = "katla:gameStats";
  constant INVALID_WORDS_KEY (line 6) | const INVALID_WORDS_KEY = "katla:invalidWords";
  constant SHAKE_ANIMATION_DURATION_MS (line 9) | const SHAKE_ANIMATION_DURATION_MS = 600;
  constant FLIP_ANIMATION_DURATION_MS (line 10) | const FLIP_ANIMATION_DURATION_MS = 800;
  constant FLIP_ANIMATION_DELAY_MS (line 11) | const FLIP_ANIMATION_DELAY_MS = 400;

FILE: utils/formatter.ts
  function formatDate (line 1) | function formatDate(d: Date) {
  function formatTime (line 8) | function formatTime(d: Date) {
  function pad0 (line 15) | function pad0(n: number): string {

FILE: utils/game.ts
  function useGame (line 32) | function useGame(hashed: string, enableStorage: boolean = true): Game {
  function useRemainingTime (line 187) | function useRemainingTime() {
  function getHoursDiff (line 219) | function getHoursDiff(date: Date) {
  function getMinutesDiff (line 227) | function getMinutesDiff(date: Date) {
  function getSecondsDiff (line 233) | function getSecondsDiff(date: Date) {
  function isGameFinished (line 237) | function isGameFinished(game: Game) {
  function getTotalWin (line 244) | function getTotalWin(stats: GameStats) {
  function getTotalPlay (line 250) | function getTotalPlay(stats: GameStats) {
  function verifyStreak (line 254) | function verifyStreak(lastCompletedDate: number | null): boolean {
  type AnswerStates (line 269) | type AnswerStates = [
  function getAnswerStates (line 277) | function getAnswerStates(
  function checkHardModeAnswer (line 309) | function checkHardModeAnswer(
  function generateMigrationLink (line 343) | function generateMigrationLink(hash: string, stats: GameStats): string {

FILE: utils/liveGame.ts
  type LiveGameState (line 22) | interface LiveGameState extends GameState {
  type LiveGame (line 26) | interface LiveGame extends Game<LiveGameState> {
  constant NEW_GAME_DELAY_MS (line 38) | const NEW_GAME_DELAY_MS = 5000;
  function useLiveGame (line 40) | function useLiveGame(words: string[]): LiveGame {
  function generateRoomId (line 223) | function generateRoomId(auth: string) {
  type Scores (line 231) | type Scores = Array<number>;
  function getUserScores (line 233) | function getUserScores(answer: string, hash: string): Scores {
  function getTotalScore (line 246) | function getTotalScore(scores: Scores) {
  function getEmojiFromScore (line 250) | function getEmojiFromScore(
  function shareInviteLink (line 265) | function shareInviteLink(config: LiveConfig, cb?: () => void) {

FILE: utils/message.ts
  function getCongratulationMessage (line 8) | function getCongratulationMessage(attempt: number, stats: GameStats) {
  function getFailureMessage (line 97) | function getFailureMessage(
  function randomElement (line 122) | function randomElement<T>(array: T[]): T {
  constant LOVE_HASHES (line 127) | const LOVE_HASHES = [
  constant EID_HASHES (line 140) | const EID_HASHES = [
  function handleSubmitWord (line 155) | function handleSubmitWord(game: Game, userAnswer: string) {
  type GameCompleteOptions (line 167) | interface GameCompleteOptions {
  function handleGameComplete (line 174) | function handleGameComplete(options: GameCompleteOptions) {
  function isEidMessage (line 192) | function isEidMessage(answer: string) {

FILE: utils/tracking.ts
  type Window (line 2) | interface Window {
  function trackEvent (line 7) | function trackEvent(

FILE: utils/types.ts
  type AnswerState (line 3) | type AnswerState = "c" | "e" | "w";
  type ForcedResult (line 4) | type ForcedResult = [column: number, state: AnswerState];
  type GameState (line 6) | interface GameState {
  type GameStats (line 17) | interface GameStats {
  type Game (line 31) | interface Game<T = GameState> {
  type MigrationData (line 44) | interface MigrationData {
  type LiveConfig (line 50) | interface LiveConfig {
  type StartEvent (line 56) | interface StartEvent {
  type EmojiEvent (line 62) | interface EmojiEvent {
  type WinEvent (line 68) | interface WinEvent {
  type LoseEvent (line 73) | interface LoseEvent {
  type LiveEvent (line 78) | type LiveEvent = StartEvent | EmojiEvent | WinEvent | LoseEvent;

FILE: utils/useStoredState.ts
  function createStoredState (line 4) | function createStoredState<T>(storageKey: string) {
Condensed preview — 69 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (193K chars).
[
  {
    "path": ".eslintrc.json",
    "chars": 40,
    "preview": "{\n  \"extends\": \"next/core-web-vitals\"\n}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 468,
    "preview": "name: test\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    if: \"!contains(gith"
  },
  {
    "path": ".github/workflows/update.yml",
    "chars": 718,
    "preview": "on:\n  schedule:\n    # TODO: use new zealand time?\n    # Update on every 00:00 GMT+9 / WIT which means 15:00 UTC\n    # Al"
  },
  {
    "path": ".gitignore",
    "chars": 469,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 58,
    "preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "chars": 29,
    "preview": "build\ncoverage\n.next\n.vercel\n"
  },
  {
    "path": ".scripts/answers.csv",
    "chars": 9456,
    "preview": "ganar,pakar,syair,ultah,danur,wisma,ruang,tajin,saksi,palsu,skrin,jeruk,vokal,belok,acian,jenuh,prabu,rahim,eceng,bocah,"
  },
  {
    "path": ".scripts/update.js",
    "chars": 2423,
    "preview": "const path = require(\"path\");\nconst fs = require(\"fs/promises\");\nconst { Octokit } = require(\"@octokit/rest\");\n\nconst an"
  },
  {
    "path": ".scripts/whitelist.csv",
    "chars": 18258,
    "preview": "abadi,abang,abate,abawi,abaya,abbas,abjad,abrar,absah,absen,abung,abuya,acang,acara,acian,acuan,adang,adika,admin,adnan,"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 72,
    "preview": "{\n  \"prettier.singleQuote\": false,\n  \"prettier.arrowParens\": \"always\"\n}\n"
  },
  {
    "path": "README.md",
    "chars": 261,
    "preview": "# Katla\n\nPermainan kata harian. ~~Imitasi~~ Terinspirasi dari [Wordle](https://www.powerlanguage.co.uk/wordle/)\n\n[![Powe"
  },
  {
    "path": "components/Alert.tsx",
    "chars": 1137,
    "preview": "import toast, { Toaster } from \"react-hot-toast\";\n\nexport default function Alert() {\n  return (\n    <Toaster\n      posit"
  },
  {
    "path": "components/App.tsx",
    "chars": 8092,
    "preview": "import { useEffect, useRef, useState } from \"react\";\n\nimport Board from \"./Board\";\nimport Keyboard from \"./Keyboard\";\n\ni"
  },
  {
    "path": "components/Board.tsx",
    "chars": 2616,
    "preview": "import Tile from \"./Tile\";\n\nimport { Game, GameState } from \"../utils/types\";\nimport { decode } from \"../utils/codec\";\ni"
  },
  {
    "path": "components/Container.tsx",
    "chars": 272,
    "preview": "import { PropsWithChildren } from \"react\";\n\nexport default function Container(props: PropsWithChildren<{}>) {\n  return ("
  },
  {
    "path": "components/EmojiRain.tsx",
    "chars": 3751,
    "preview": "import { ComponentRef, useEffect, useRef, useState } from \"react\";\n\nconst CUSTOM_EVENT_NAME = \"emoji-rain\";\n\nexport defa"
  },
  {
    "path": "components/EmojiSelector.tsx",
    "chars": 1390,
    "preview": "import { Menu, MenuButton, MenuList, MenuItem } from \"@reach/menu-button\";\nimport { memo } from \"react\";\n\ninterface Prop"
  },
  {
    "path": "components/Header.tsx",
    "chars": 6111,
    "preview": "import Head from \"next/head\";\nimport dynamic from \"next/dynamic\";\nimport React, { ReactNode } from \"react\";\n\nconst Emoji"
  },
  {
    "path": "components/HeadingWithNum.tsx",
    "chars": 761,
    "preview": "interface Props {\n  num: string | number | null;\n  enableLiarMode?: boolean;\n}\n\nexport default function HeadingWithNum(p"
  },
  {
    "path": "components/HelpModal.tsx",
    "chars": 3211,
    "preview": "import { memo } from \"react\";\nimport Modal from \"./Modal\";\nimport Tile from \"./Tile\";\n\ninterface Props {\n  isOpen: boole"
  },
  {
    "path": "components/Keyboard.tsx",
    "chars": 4657,
    "preview": "import { MutableRefObject, useEffect, useRef } from \"react\";\n\nimport KeyboardButton from \"./KeyboardButton\";\nimport { An"
  },
  {
    "path": "components/KeyboardButton.tsx",
    "chars": 845,
    "preview": "import { ComponentProps, memo } from \"react\";\nimport { AnswerState } from \"../utils/types\";\n\ntype Props = {\n  state: Ans"
  },
  {
    "path": "components/Link.tsx",
    "chars": 293,
    "preview": "import NextLink from \"next/link\";\nimport { ComponentProps } from \"react\";\n\nexport default function Link(props: Component"
  },
  {
    "path": "components/LiveStatsModal.tsx",
    "chars": 2047,
    "preview": "import Modal from \"./Modal\";\nimport Alert from \"./Alert\";\nimport { useMap, useOthers, useSelf } from \"@liveblocks/react\""
  },
  {
    "path": "components/Modal.tsx",
    "chars": 2636,
    "preview": "import { DialogOverlay, DialogContent } from \"@reach/dialog\";\nimport { useEffect, useState, ReactNode, useCallback } fro"
  },
  {
    "path": "components/SettingsModal.tsx",
    "chars": 7484,
    "preview": "import { useTheme } from \"next-themes\";\n\nimport Link from \"./Link\";\nimport Modal from \"./Modal\";\nimport Alert from \"./Al"
  },
  {
    "path": "components/SponsorshipFooter.tsx",
    "chars": 279,
    "preview": "export default function SponsorshipFooter() {\n  return (\n    <footer className=\"text-sm pb-4\">\n      Built with{\" \"}\n   "
  },
  {
    "path": "components/StatsModal.tsx",
    "chars": 16739,
    "preview": "import useSWR from \"swr\";\nimport { LegacyRef, useEffect, useRef, useState } from \"react\";\nimport { useTheme } from \"next"
  },
  {
    "path": "components/Tile.tsx",
    "chars": 2134,
    "preview": "import { CSSProperties, useEffect, useState } from \"react\";\nimport {\n  FLIP_ANIMATION_DELAY_MS,\n  FLIP_ANIMATION_DURATIO"
  },
  {
    "path": "env.dev",
    "chars": 32,
    "preview": "NEXT_PUBLIC_DEFINE_TOKEN=\"test\"\n"
  },
  {
    "path": "jest.config.js",
    "chars": 6711,
    "preview": "/*\n * For a detailed explanation regarding each configuration property and type check, visit:\n * https://jestjs.io/docs/"
  },
  {
    "path": "next-env.d.ts",
    "chars": 201,
    "preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
  },
  {
    "path": "next.config.js",
    "chars": 239,
    "preview": "const { withSentryConfig } = require(\"@sentry/nextjs\");\n\nconst nextConfig = {\n  reactStrictMode: true,\n};\n\nconst sentryW"
  },
  {
    "path": "package.json",
    "chars": 1365,
    "preview": "{\n  \"name\": \"katla\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"n"
  },
  {
    "path": "pages/_app.tsx",
    "chars": 816,
    "preview": "import Script from \"next/script\";\n\nimport \"../styles/globals.css\";\nimport { ThemeProvider } from \"next-themes\";\nimport A"
  },
  {
    "path": "pages/_document.tsx",
    "chars": 528,
    "preview": "import { Html, Head, Main, NextScript } from \"next/document\";\n\nexport default function Document() {\n  return (\n    <Html"
  },
  {
    "path": "pages/_error.js",
    "chars": 2318,
    "preview": "import NextErrorComponent from \"next/error\";\n\nimport * as Sentry from \"@sentry/nextjs\";\n\nconst MyError = ({ statusCode, "
  },
  {
    "path": "pages/api/define/[word].ts",
    "chars": 2461,
    "preview": "import { withSentry } from \"@sentry/nextjs\";\nimport cheerio from \"cheerio\";\nimport { NextApiRequest, NextApiResponse } f"
  },
  {
    "path": "pages/api/live.ts",
    "chars": 930,
    "preview": "import { authorize } from \"@liveblocks/node\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { NextApiRequ"
  },
  {
    "path": "pages/api/words.ts",
    "chars": 1442,
    "preview": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nexport default async function handler(\n  req: NextApiRequest,\n "
  },
  {
    "path": "pages/arsip/[num].tsx",
    "chars": 3681,
    "preview": "import { GetStaticPaths, GetStaticProps } from \"next\";\nimport { ComponentProps, useState } from \"react\";\n\nimport App fro"
  },
  {
    "path": "pages/arsip/index.tsx",
    "chars": 2753,
    "preview": "import { useState } from \"react\";\n\nimport Container from \"../../components/Container\";\nimport Header from \"../../compone"
  },
  {
    "path": "pages/bantuan.tsx",
    "chars": 4484,
    "preview": "import { FormEvent, useEffect, useState } from \"react\";\nimport LocalStorage from \"../utils/browser\";\nimport {\n  GAME_STA"
  },
  {
    "path": "pages/index.tsx",
    "chars": 5960,
    "preview": "import * as Sentry from \"@sentry/nextjs\";\nimport fs from \"fs/promises\";\nimport { GetStaticProps } from \"next\";\nimport { "
  },
  {
    "path": "pages/lawan.tsx",
    "chars": 8519,
    "preview": "import { createClient } from \"@liveblocks/client\";\nimport {\n  LiveblocksProvider,\n  RoomProvider,\n  useBroadcastEvent,\n "
  },
  {
    "path": "postcss.config.js",
    "chars": 83,
    "preview": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "public/ads.txt",
    "chars": 58,
    "preview": "google.com, pub-3081263972680635, DIRECT, f08c47fec0942fa0"
  },
  {
    "path": "public/google563f40b6a67a6d75.html",
    "chars": 54,
    "preview": "google-site-verification: google563f40b6a67a6d75.html\n"
  },
  {
    "path": "sentry.client.config.js",
    "chars": 686,
    "preview": "// This file configures the initialization of Sentry on the browser.\n// The config you add here will be used whenever a "
  },
  {
    "path": "sentry.properties",
    "chars": 184,
    "preview": "defaults.url=https://sentry.io/\ndefaults.org=pveyes\ndefaults.project=katla\ncli.executable=../../.npm/_npx/26968/lib/node"
  },
  {
    "path": "sentry.server.config.js",
    "chars": 486,
    "preview": "import * as Sentry from \"@sentry/nextjs\";\n\nconst SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_D"
  },
  {
    "path": "styles/globals.css",
    "chars": 2273,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  @apply dark:bg-gray-900 bg-white min-h-screen max-h"
  },
  {
    "path": "tailwind.config.js",
    "chars": 357,
    "preview": "module.exports = {\n  content: [\n    \"./pages/**/*.{js,ts,jsx,tsx}\",\n    \"./components/**/*.{js,ts,jsx,tsx}\",\n  ],\n  dark"
  },
  {
    "path": "tsconfig.json",
    "chars": 510,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"sk"
  },
  {
    "path": "utils/__tests__/answer.test.js",
    "chars": 486,
    "preview": "import { getAnswerStates } from \"../game\";\n\ntest(\"should mark `c` characters\", () => {\n  expect(getAnswerStates(\"SEMUA\","
  },
  {
    "path": "utils/__tests__/game.test.js",
    "chars": 5450,
    "preview": "/**\n * @jest-environment jsdom\n */\njest.useFakeTimers();\n\nimport { renderHook } from \"@testing-library/react-hooks\";\n\nim"
  },
  {
    "path": "utils/animation.ts",
    "chars": 710,
    "preview": "import showConfetti from \"canvas-confetti\";\n\nexport default function confetti(duration: number = 3) {\n  let animationFra"
  },
  {
    "path": "utils/answers.ts",
    "chars": 291,
    "preview": "import fs from \"fs/promises\";\nimport path from \"path\";\n\nexport function getAllAnswers() {\n  return fs\n    .readFile(path"
  },
  {
    "path": "utils/browser.ts",
    "chars": 4332,
    "preview": "import Alert from \"../components/Alert\";\n\nexport function checkNativeShareSupport() {\n  const isFirefox = navigator.user"
  },
  {
    "path": "utils/codec.ts",
    "chars": 1188,
    "preview": "export function encode(word: string): string {\n  const base64 = Buffer.from(word).toString(\"base64\");\n  const equalSigns"
  },
  {
    "path": "utils/constants.ts",
    "chars": 475,
    "preview": "export const LAST_HASH_KEY = \"katla:lastHash\";\nexport const LAST_SESSION_RESET_KEY = \"katla:lastSessionReset\";\nexport co"
  },
  {
    "path": "utils/fetcher.ts",
    "chars": 124,
    "preview": "const fetcher = (...args: Parameters<typeof fetch>) =>\n  fetch(...args).then((res) => res.json());\n\nexport default fetch"
  },
  {
    "path": "utils/formatter.ts",
    "chars": 462,
    "preview": "export function formatDate(d: Date) {\n  const year = d.getFullYear();\n  const month = d.getMonth() + 1;\n  const date = d"
  },
  {
    "path": "utils/game.ts",
    "chars": 9671,
    "preview": "import { useEffect, useState } from \"react\";\nimport { useRouter } from \"next/router\";\n\nimport { LAST_HASH_KEY, GAME_STAT"
  },
  {
    "path": "utils/liveGame.ts",
    "chars": 6778,
    "preview": "import { useEffect, useState } from \"react\";\nimport {\n  useBroadcastEvent,\n  useEventListener,\n  useList,\n  useOthers,\n "
  },
  {
    "path": "utils/message.ts",
    "chars": 4351,
    "preview": "import { getTotalPlay } from \"./game\";\nimport { Game, AnswerState, GameStats } from \"./types\";\nimport confetti from \"./a"
  },
  {
    "path": "utils/tracking.ts",
    "chars": 286,
    "preview": "declare global {\n  interface Window {\n    gtag: (...args: any[]) => void;\n  }\n}\n\nexport function trackEvent(\n  eventName"
  },
  {
    "path": "utils/types.ts",
    "chars": 1516,
    "preview": "import { Dispatch, SetStateAction } from \"react\";\n\nexport type AnswerState = \"c\" | \"e\" | \"w\";\nexport type ForcedResult ="
  },
  {
    "path": "utils/useStoredState.ts",
    "chars": 993,
    "preview": "import { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport LocalStorage from \"./browser\";\n\nexport def"
  }
]

About this extraction

This page contains the full source code of the pveyes/katla GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 69 files (176.7 KB), approximately 54.4k tokens, and a symbol index with 171 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!