[
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"extends\": \"next/core-web-vitals\"\n}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    if: \"!contains(github.event.head_commit.message, '[skip ci]')\"\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - name: Use Node.js 14.x\n        uses: actions/setup-node@v1\n        with:\n          node-version: \"14.x\"\n      - name: Install & test\n        run: |\n          yarn install --pure-lockfile --prefer-offline\n          yarn test\n"
  },
  {
    "path": ".github/workflows/update.yml",
    "content": "on:\n  schedule:\n    # TODO: use new zealand time?\n    # Update on every 00:00 GMT+9 / WIT which means 15:00 UTC\n    # Also sync timezone change to update.js/TIMEZONE_OFFSET\n    - cron: \"00 15 * * *\"\n\njobs:\n  update_katla:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v1\n      - uses: actions/setup-node@v1\n        with:\n          node-version: \"14.x\"\n          registry-url: \"https://registry.npmjs.org\"\n      - name: install deps\n        run: yarn install --prefer-offline\n      - name: update new word\n        run: yarn update\n        env:\n          SECRET_DATE: ${{ secrets.SECRET_DATE }}\n          SECRET_WORD: ${{ secrets.SECRET_WORD }}\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n.scripts/wordle.json\n.scripts/nyt.json\n.scripts/analysis.js\n\n# vercel\n.vercel\n\n# Sentry\n.sentryclirc\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "build\ncoverage\n.next\n.vercel\n"
  },
  {
    "path": ".scripts/answers.csv",
    "content": "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\n"
  },
  {
    "path": ".scripts/update.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs/promises\");\nconst { Octokit } = require(\"@octokit/rest\");\n\nconst answerPath = path.join(__dirname, \"answers.csv\");\nconst wordsPath = path.join(__dirname, \"whitelist.csv\");\n\n// sync with update.yml\nconst TIMEZONE_OFFSET = 9;\n\nconst readCsv = (filePath) =>\n  fs.readFile(filePath, \"utf-8\").then((text) =>\n    text\n      .split(\",\")\n      .map((s) => s.trim())\n      .filter(Boolean)\n  );\n\nasync function insertWord(answers, answer) {\n  if (process.env.GITHUB_ACTIONS) {\n    // GH actions\n    await writeCommit(answers.concat(answer).join(\",\") + \"\\n\");\n  } else {\n    // dev testing\n    await fs.writeFile(answerPath, answers.concat(answer).join(\",\"), \"utf-8\");\n  }\n}\n\nasync function writeCommit(data) {\n  const token = process.env.GITHUB_TOKEN;\n  if (!token) {\n    throw new Error(\"Missing GitHub token env in `GITHUB_TOKEN`\");\n  }\n\n  const FileInfo = {\n    owner: \"pveyes\",\n    repo: \"katla\",\n    path: \".scripts/answers.csv\",\n    sha: \"main\",\n  };\n\n  const octokit = new Octokit({\n    baseUrl: \"https://api.github.com\",\n    auth: `token ${token}`,\n  });\n\n  const response = await octokit.repos.getContent(FileInfo);\n  const { sha } = response.data;\n\n  octokit.repos.createOrUpdateFileContents({\n    ...FileInfo,\n    message: \"Insert new answer\",\n    content: Buffer.from(data).toString(\"base64\"),\n    sha,\n    branch: \"main\",\n  });\n}\n\nfunction getMidnightDate() {\n  const now = new Date();\n  now.setHours(now.getHours() + TIMEZONE_OFFSET);\n  const deltaToMidnightMinutes = 60 - now.getMinutes();\n  now.setMinutes(now.getMinutes() + deltaToMidnightMinutes);\n  return now;\n}\n\nasync function main() {\n  const [usedWords, allWords] = await Promise.all([\n    readCsv(answerPath),\n    readCsv(wordsPath),\n  ]);\n\n  const validWords = allWords.filter((word) => !usedWords.includes(word));\n\n  // use let to allow secret words\n  let word = validWords[Math.floor(Math.random() * validWords.length)];\n  const secretDate = process.env.SECRET_DATE;\n  const secretWord = process.env.SECRET_WORD;\n  if (secretDate && secretWord) {\n    const date = getMidnightDate();\n    const [mm, dd] = secretDate.split(\"-\").map(Number);\n    if (date.getDate() == dd && date.getMonth() + 1 === mm) {\n      word = secretWord;\n    }\n  }\n\n  await insertWord(usedWords, word);\n  console.log(\"New word inserted\", word);\n}\n\nmain().catch((error) => {\n  console.error(\"Failed\", error);\n  process.exit(1);\n});\n"
  },
  {
    "path": ".scripts/whitelist.csv",
    "content": "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\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"prettier.singleQuote\": false,\n  \"prettier.arrowParens\": \"always\"\n}\n"
  },
  {
    "path": "README.md",
    "content": "# Katla\n\nPermainan kata harian. ~~Imitasi~~ Terinspirasi dari [Wordle](https://www.powerlanguage.co.uk/wordle/)\n\n[![Powered by Vercel](https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg)](https://vercel.com?utm_source=katla&utm_campaign=oss)\n"
  },
  {
    "path": "components/Alert.tsx",
    "content": "import toast, { Toaster } from \"react-hot-toast\";\n\nexport default function Alert() {\n  return (\n    <Toaster\n      position=\"top-center\"\n      gutter={8}\n      toastOptions={Alert.options}\n      containerStyle={{ top: 180 }}\n    />\n  );\n}\n\nAlert.options = {\n  duration: 750,\n  className:\n    \"bg-gray-900 text-white dark:bg-white dark:text-black text-center font-semibold py-2 px-3 rounded-sm\",\n};\n\ninterface AlertOptions {\n  duration?: number;\n  cb?: () => void;\n  id: string;\n}\n\nAlert.show = (message: string, options: AlertOptions) => {\n  const duration = options.duration || Alert.options.duration;\n\n  let formatted: any = message;\n  if (message.includes(\"\\n\")) {\n    const lines = message.split(\"\\n\");\n    formatted = (\n      <div>\n        {lines.map((line, i) => {\n          if (i === lines.length) {\n            return line;\n          }\n\n          return (\n            <>\n              {line}\n              <br />\n            </>\n          );\n        })}\n      </div>\n    );\n  }\n\n  toast(formatted, {\n    id: options.id,\n    duration,\n  });\n\n  if (options.cb) {\n    setTimeout(() => {\n      options.cb();\n    }, duration);\n  }\n};\n"
  },
  {
    "path": "components/App.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport Board from \"./Board\";\nimport Keyboard from \"./Keyboard\";\n\nimport { GameStats } from \"../utils/types\";\nimport { decode } from \"../utils/codec\";\nimport { handleGameComplete, getFailureMessage } from \"../utils/message\";\nimport {\n  checkHardModeAnswer,\n  getAnswerStates,\n  verifyStreak,\n} from \"../utils/game\";\nimport { Game } from \"../utils/types\";\nimport { trackEvent } from \"../utils/tracking\";\nimport Alert from \"./Alert\";\n\ninterface Props {\n  game: Game;\n  stats: GameStats;\n  words: string[];\n  setStats: (stats: GameStats) => void;\n  showStats: () => void;\n  // optional event handlers for different game modes\n  onSubmit?: (game: Game, userAnswer: string) => void;\n  onComplete?: typeof handleGameComplete;\n}\n\nexport default function App(props: Props) {\n  const { game, stats, setStats, showStats, words } = props;\n\n  const [invalidAnswer, setInvalidAnswer] = useState(false);\n  const isAnimating = useRef(null);\n\n  const answer = decode(game.hash);\n\n  function handlePressChar(char: string) {\n    // ignore if already finished\n    if (game.state.answers[game.state.attempt - 1] === answer) {\n      return;\n    }\n\n    if (isAnimating.current) {\n      return;\n    }\n\n    if (!game.state.enableFreeEdit && char === \"_\") {\n      return;\n    }\n\n    game.setState({\n      ...game.state,\n      answers: game.state.answers.map((answer, i) => {\n        if (i === game.state.attempt) {\n          if (answer.length === 5) {\n            if (answer.includes(\"_\")) {\n              return answer.replace(\"_\", char.toLowerCase());\n            }\n\n            // do nothing\n            return answer;\n          }\n\n          return answer + char.toLowerCase();\n        }\n\n        return answer;\n      }),\n    });\n  }\n\n  function handleBackspace() {\n    if (isAnimating.current) {\n      return;\n    }\n\n    game.setState({\n      ...game.state,\n      answers: game.state.answers.map((answer, i) => {\n        if (i === game.state.attempt) {\n          return answer.slice(0, -1);\n        }\n\n        return answer;\n      }),\n    });\n  }\n\n  function handleSubmit() {\n    if (isAnimating.current) {\n      return;\n    }\n\n    // ignore submission user already know the answer\n    if (\n      // already fail\n      game.state.attempt === 6 ||\n      // already found the answer\n      game.state.answers[game.state.attempt - 1] === answer\n    ) {\n      return;\n    }\n\n    const userAnswer = game.state.answers[game.state.attempt]\n      .split(\"\")\n      .filter((char) => char !== \"_\")\n      .join(\"\");\n\n    if (userAnswer.length < 5) {\n      markInvalid();\n      Alert.show(\"Tidak cukup huruf\", { id: \"answer\" });\n      return;\n    }\n\n    if (!words.includes(userAnswer.toLowerCase())) {\n      markInvalid();\n      Alert.show(\"Tidak ada dalam KBBI\", { id: \"answer\" });\n      game.trackInvalidWord(userAnswer);\n      return;\n    }\n\n    if (game.state.enableHardMode && game.state.attempt > 0) {\n      const [isInvalid, unusedChar, leterIndex] = checkHardModeAnswer(\n        game.state,\n        answer\n      );\n      if (isInvalid) {\n        markInvalid();\n        if (leterIndex) {\n          Alert.show(`Huruf ke-${leterIndex} harus ${unusedChar}`, {\n            id: \"answer\",\n          });\n        } else {\n          Alert.show(`Huruf ${unusedChar} harus dipakai`, { id: \"answer\" });\n        }\n        return;\n      }\n    }\n\n    props.onSubmit?.(game, userAnswer);\n\n    setInvalidAnswer(false);\n    game.submitAnswer?.(userAnswer, game.state.attempt + 1);\n    game.setState({\n      ...game.state,\n      answers: game.state.answers.map((answer, i) => {\n        if (i === game.state.attempt) {\n          return userAnswer;\n        }\n\n        return answer;\n      }),\n      attempt: game.state.attempt + 1,\n      lastCompletedDate: game.state.lastCompletedDate,\n    });\n\n    isAnimating.current = true;\n    setTimeout(() => {\n      isAnimating.current = false;\n\n      if (answer === userAnswer) {\n        if (typeof game.submitAnswer === \"function\") {\n          game.setState({\n            ...game.state,\n            answers: game.state.answers.map((answer, i) => {\n              if (i === game.state.attempt) {\n                return userAnswer;\n              }\n\n              return answer;\n            }),\n            attempt: game.state.attempt + 1,\n            lastCompletedDate: new Date().getTime(),\n          });\n\n          return;\n        }\n\n        props.onComplete?.({\n          hash: game.hash,\n          attempt: game.state.attempt + 1,\n          stats: stats,\n          cb: showStats,\n        });\n\n        trackEvent(\"succeed\", {\n          hash: game.hash,\n          attempt: game.state.attempt + 1,\n        });\n        const currentStreak = stats.currentStreak + 1;\n\n        game.setState({\n          ...game.state,\n          answers: game.state.answers.map((answer, i) => {\n            if (i === game.state.attempt) {\n              return userAnswer;\n            }\n\n            return answer;\n          }),\n          attempt: game.state.attempt + 1,\n          lastCompletedDate: new Date().getTime(),\n        });\n\n        setStats({\n          distribution: {\n            ...stats.distribution,\n            [game.state.attempt + 1]:\n              stats.distribution[game.state.attempt + 1] + 1,\n          },\n          currentStreak,\n          maxStreak: Math.max(stats.maxStreak, currentStreak),\n        });\n      } else if (game.state.attempt === 5) {\n        if (typeof game.submitAnswer === \"function\") {\n          game.setState({\n            ...game.state,\n            answers: game.state.answers.map((answer, i) => {\n              if (i === game.state.attempt) {\n                return userAnswer;\n              }\n\n              return answer;\n            }),\n            attempt: game.state.attempt + 1,\n            lastCompletedDate: new Date().getTime(),\n          });\n\n          return;\n        }\n\n        trackEvent(\"failed\", { hash: game.hash });\n        setStats({\n          distribution: {\n            ...stats.distribution,\n            fail: stats.distribution.fail + 1,\n          },\n          currentStreak: 0,\n          maxStreak: stats.maxStreak,\n        });\n\n        const failureMessage = getFailureMessage(\n          stats,\n          getAnswerStates(game.state.answers[game.state.attempt], answer)\n        );\n        Alert.show(`${failureMessage}. Jawaban: ${answer}`, {\n          id: \"finish\",\n          duration: 1250,\n          cb: showStats,\n        });\n      }\n    }, 400 * 6);\n  }\n\n  function markInvalid() {\n    setInvalidAnswer(true);\n    setTimeout(() => {\n      setInvalidAnswer(false);\n    }, 600);\n  }\n\n  // auto resize board game to fit screen\n  useEffect(() => {\n    if (!game.ready) {\n      return;\n    }\n\n    function handleResize() {\n      const katla = document.querySelector(\"#katla\") as HTMLDivElement;\n      const footerHeight =\n        document.querySelector(\"footer\")?.getBoundingClientRect()?.height ?? 0;\n      const maxTileHeight =\n        window.innerHeight -\n        document.querySelector(\"#header\").getBoundingClientRect().height -\n        document.querySelector(\"#keyboard\").getBoundingClientRect().height -\n        footerHeight;\n      document.querySelector(\"#game-bar\")?.getBoundingClientRect()?.height ?? 0;\n      const maxTileSize = Math.min(maxTileHeight, window.innerWidth);\n      const singleTileSize = Math.max(Math.floor((maxTileSize - 30) / 6), 62);\n\n      const tileWidth = 5 * singleTileSize + 42;\n      katla.style.height = maxTileSize + \"px\";\n      katla.style.width = tileWidth + \"px\";\n    }\n\n    handleResize();\n    window.addEventListener(\"resize\", handleResize);\n    return () => window.removeEventListener(\"resize\", handleResize);\n  }, [game.ready]);\n\n  return (\n    <>\n      <div className=\"mx-auto max-w-full px-4 flex justify-center items-center grow-0 shrink\">\n        <Board game={game} invalidAnswer={invalidAnswer} />\n      </div>\n      <Keyboard\n        gameState={game.state}\n        hash={game.hash}\n        onPressChar={handlePressChar}\n        onBackspace={handleBackspace}\n        onSubmit={handleSubmit}\n        isAnimating={isAnimating}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Board.tsx",
    "content": "import Tile from \"./Tile\";\n\nimport { Game, GameState } from \"../utils/types\";\nimport { decode } from \"../utils/codec\";\nimport { getAnswerStates } from \"../utils/game\";\nimport { FLIP_ANIMATION_DELAY_MS } from \"../utils/constants\";\n\ninterface Props {\n  game: Game;\n  invalidAnswer: boolean;\n}\n\nexport default function Board(props: Props) {\n  const { game, invalidAnswer } = props;\n  const answer = decode(game.hash);\n\n  function handlePress(row: number, index: number) {\n    if (game.state.enableFreeEdit && row === game.state.attempt) {\n      game.setState({\n        ...game.state,\n        answers: game.state.answers.map((answer, i) => {\n          if (i === game.state.attempt) {\n            return answer.slice(0, index) + \"_\" + answer.slice(index + 1);\n          }\n\n          return answer;\n        }),\n      });\n    }\n  }\n\n  return (\n    <div\n      className=\"grid grid-rows-6 gap-1.5 max-w-full\"\n      style={{ aspectRatio: \"1 / 1\" }}\n      id=\"katla\"\n    >\n      {Array(6)\n        .fill(\"\")\n        .map((_, i) => {\n          let userAnswer = game.state.answers[i] ?? \"\";\n          userAnswer += \" \".repeat(5 - userAnswer.length);\n\n          const answerStates = getAnswerStates(userAnswer, answer);\n          const lieBoxes = game.state.lieBoxes?.[i];\n          const isTheAnswer = answerStates.every((answer) => answer === \"c\");\n\n          return (\n            <div className=\"grid grid-cols-5 gap-1.5 relative\" key={i}>\n              {userAnswer.split(\"\").map((char, index) => {\n                let state = null;\n                if (i < game.state.attempt) {\n                  state = answerStates[index];\n                  const [forcedColumn, forcedState] = lieBoxes ?? [];\n                  if (\n                    // only replace the box if liar mode is enabled\n                    game.state.enableLiarMode &&\n                    // and the column matches\n                    forcedColumn === index &&\n                    // and it's not the answer\n                    !isTheAnswer\n                  ) {\n                    state = forcedState;\n                  }\n                }\n\n                const isInvalid = invalidAnswer && i === game.state.attempt;\n\n                return (\n                  <Tile\n                    key={`${index}-${char}`}\n                    char={char}\n                    state={state}\n                    isInvalid={isInvalid}\n                    delay={FLIP_ANIMATION_DELAY_MS * index}\n                    onPress={() => handlePress(i, index)}\n                  />\n                );\n              })}\n            </div>\n          );\n        })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/Container.tsx",
    "content": "import { PropsWithChildren } from \"react\";\n\nexport default function Container(props: PropsWithChildren<{}>) {\n  return (\n    <div className=\"text-gray-800 dark:text-white text-center flex flex-col items-stretch overflow-y-hidden\">\n      {props.children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/EmojiRain.tsx",
    "content": "import { ComponentRef, useEffect, useRef, useState } from \"react\";\n\nconst CUSTOM_EVENT_NAME = \"emoji-rain\";\n\nexport default function EmojiRain() {\n  const canvasRef = useRef<ComponentRef<\"canvas\">>(null);\n  const [emoji, setEmoji] = useState(null);\n  const timeoutRef = useRef<any>();\n\n  useEffect(() => {\n    if (!emoji) {\n      return;\n    }\n\n    if (canvasRef.current === null) {\n      return;\n    }\n\n    const w = window.innerWidth;\n    const h = window.innerHeight;\n    const ctx = canvasRef.current.getContext(\"2d\");\n    const bodyBackground = window.getComputedStyle(\n      document.body\n    ).backgroundColor;\n\n    canvasRef.current.width = w;\n    canvasRef.current.height = h;\n\n    ctx.fillStyle = bodyBackground;\n    ctx.fillRect(0, 0, w - 1, h - 1);\n\n    const numOfEmojis = randomBetween(20, 30);\n    let emojis: ElementPosition[] = Array(numOfEmojis)\n      .fill(emoji)\n      .map((emoji) => {\n        let x = randomBetween(0, w - 1);\n        let y = randomBetween(-42, -600);\n        let size = randomBetween(32, 60);\n        return new ElementPosition(emoji, x, y, size, size);\n      });\n\n    let frame = window.requestAnimationFrame(function draw() {\n      const bodyBackground = window.getComputedStyle(\n        document.body\n      ).backgroundColor;\n      emojis.forEach((element) => {\n        element.updateY(\n          interpolate(element.y, { min: -42, max: h }, { min: 8, max: 1 })\n        );\n      });\n\n      emojis = emojis.filter((elem) => {\n        if (elem.y > h + 42) {\n          return false;\n        }\n        return true;\n      });\n\n      ctx.fillStyle = bodyBackground;\n      ctx.fillRect(0, 0, w - 1, h - 1);\n\n      emojis.forEach((elem) => {\n        const alpha = interpolate(\n          elem.y,\n          { min: 0, max: (h / 3) * 2 },\n          { min: 1, max: 0 }\n        );\n        ctx.fillStyle = `rgb(17, 24, 39, ${alpha})`;\n        ctx.font = elem.width + \"px sans\";\n        ctx.fillText(elem.emoji, elem.x, elem.y);\n      });\n\n      frame = window.requestAnimationFrame(draw);\n    });\n    return () => {\n      ctx.fillStyle = bodyBackground;\n      ctx.fillRect(0, 0, w - 1, h - 1);\n      window.cancelAnimationFrame(frame);\n    };\n  }, [emoji]);\n\n  useEffect(() => {\n    function handleEmoji(e: CustomEvent) {\n      setEmoji(e.detail);\n      clearTimeout(timeoutRef.current);\n      timeoutRef.current = setTimeout(() => {\n        setEmoji(null);\n      }, 5000);\n    }\n\n    window.addEventListener(CUSTOM_EVENT_NAME, handleEmoji);\n    return () => window.removeEventListener(CUSTOM_EVENT_NAME, handleEmoji);\n  }, []);\n\n  return <canvas ref={canvasRef} className=\"fixed pointer-events-none z-0\" />;\n}\n\nexport function rainEmoji(emoji: string) {\n  const event = new CustomEvent(CUSTOM_EVENT_NAME, { detail: emoji });\n  window.dispatchEvent(event);\n}\n\ninterface RangeValue {\n  min: number;\n  max: number;\n}\n\nfunction interpolate(current: number, from: RangeValue, to: RangeValue) {\n  if (current < from.min) {\n    return to.min;\n  }\n  if (current > from.max) {\n    return to.max;\n  }\n  // y-y1/y2-y1 = x-x1/x2-x1\n  // y = x-x1/x2-x1*y2-y1 + y1\n  return (\n    ((current - from.min) / (from.max - from.min)) * (to.max - to.min) + to.min\n  );\n}\n\nfunction randomBetween(min: number, max: number) {\n  min = Math.ceil(min);\n  max = Math.floor(max);\n  return Math.floor(Math.random() * (max - min + 1)) + min;\n}\n\nclass ElementPosition {\n  emoji: string;\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  velocity: number;\n\n  constructor(\n    emoji: string,\n    x: number,\n    y: number,\n    width: number,\n    height: number\n  ) {\n    this.emoji = emoji;\n    this.x = x;\n    this.y = y;\n    this.width = width;\n    this.height = height;\n  }\n\n  updateY(velocity: number) {\n    this.y = this.y + velocity;\n  }\n}\n"
  },
  {
    "path": "components/EmojiSelector.tsx",
    "content": "import { Menu, MenuButton, MenuList, MenuItem } from \"@reach/menu-button\";\nimport { memo } from \"react\";\n\ninterface Props {\n  onSendEmoji: (emoji: string) => void;\n}\n\nfunction EmojiSelector(props: Props) {\n  return (\n    <Menu>\n      <MenuButton>\n        <svg\n          viewBox=\"0 0 24 24\"\n          width={22}\n          height={22}\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          fill=\"none\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n          <path d=\"M8 14s1.5 2 4 2 4-2 4-2\"></path>\n          <line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line>\n          <line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>\n        </svg>\n      </MenuButton>\n      <EmojiBar onSelect={props.onSendEmoji} />\n    </Menu>\n  );\n}\n\nexport default memo(EmojiSelector);\n\nfunction EmojiBar({ onSelect }) {\n  const emojis = [\"🎉\", \"🥳\", \"😂\", \"😭\", \"❤️\", \"🤬\"];\n\n  return (\n    <MenuList className=\"rounded-full bg-gray-100 border-gray-400 border dark:bg-gray-800 px-2 absolute top-2 z-10 slide-down\">\n      {emojis.map((emoji) => (\n        <MenuItem\n          key={emoji}\n          className=\"p-2 text-xl select-none focus:outline-none transform transition-transform hover:scale-150 focus:scale-150\"\n          onSelect={() => onSelect(emoji)}\n        >\n          {emoji}\n        </MenuItem>\n      ))}\n    </MenuList>\n  );\n}\n"
  },
  {
    "path": "components/Header.tsx",
    "content": "import Head from \"next/head\";\nimport dynamic from \"next/dynamic\";\nimport React, { ReactNode } from \"react\";\n\nconst EmojiSelector = dynamic(() => import(\"./EmojiSelector\"), { ssr: false });\n\ninterface Props {\n  title?: string;\n  description?: string;\n  keywords?: string[];\n  ogImage?: string;\n  customHeading?: ReactNode;\n  warnStorageDisabled?: boolean;\n  isLiveMode?: boolean;\n  themeColor?: string;\n  showLiarOption?: boolean;\n  onShowStats?: () => void;\n  onShowHelp?: () => void;\n  onShowSettings?: () => void;\n  onSendEmoji?: (emoji: string) => void;\n  path?: string;\n}\n\nexport default function Header(props: Props) {\n  const {\n    title = \"Katla - Permainan Tebak Kata | 1 Hari 1 Kata 6 Kesempatan\",\n    description = \"Tebak kata rahasia dalam 6 percobaan. Kata baru tersedia setiap hari.\",\n    keywords = [\n      \"game\",\n      \"permainan\",\n      \"main\",\n      \"tebak\",\n      \"kata\",\n      \"rahasia\",\n      \"sembunyi\",\n      \"clue\",\n      \"petunjuk\",\n      \"wordle\",\n      \"bahasa\",\n      \"indonesia\",\n      \"karya\",\n      \"anak\",\n      \"bangsa\",\n      \"kbbi\",\n    ],\n    ogImage = \"https://katla.vercel.app/og.png\",\n    customHeading,\n    onShowStats,\n    onShowHelp,\n    onShowSettings,\n    onSendEmoji,\n    warnStorageDisabled,\n    isLiveMode,\n    themeColor = \"#15803D\",\n    showLiarOption,\n    path = \"/\",\n  } = props;\n\n  return (\n    <header className=\"px-4 mx-auto max-w-lg w-full pt-2 pb-4\" id=\"header\">\n      <Head>\n        <title>{title}</title>\n        <meta name=\"description\" content={description} />\n        <meta name=\"keywords\" content={keywords.join(\", \")} />\n        <meta property=\"og:url\" content=\"https://katla.vercel.app/\" />\n        <meta property=\"og:type\" content=\"website\" />\n        <meta property=\"og:title\" content={title} />\n        <meta property=\"og:description\" content={description} />\n        <meta property=\"og:keywords\" content={keywords.join(\", \")} />\n        <meta property=\"og:image\" content={ogImage} />\n        <link rel=\"canonical\" href={\"https://katla.id\" + path} />\n\n        <meta name=\"twitter:card\" content=\"summary_large_image\" />\n        <meta property=\"twitter:domain\" content=\"katla.vercel.app\" />\n\n        <meta name=\"theme-color\" content={themeColor} />\n        <link href=\"/katla-32x32.png\" rel=\"icon shortcut\" sizes=\"3232\" />\n        <link href=\"/katla-192x192.png\" rel=\"apple-touch-icon\" />\n      </Head>\n      {isLiveMode && (\n        <div className=\"text-xs mb-2 text-yellow-800 dark:text-yellow-200\">\n          Mode lawan masih dalam tahap uji coba.\n        </div>\n      )}\n      {showLiarOption && (\n        <div className=\"text-xs mb-2\">\n          Kurang menantang? Gunakan{\" \"}\n          <button onClick={onShowSettings} className=\"color-accent\">\n            mode bohong\n          </button>\n        </div>\n      )}\n      {warnStorageDisabled && (\n        <div className=\"text-xs mb-2 text-yellow-800 dark:text-yellow-200\">\n          Browser yang kamu gunakan saat ini tidak dapat menyimpan progres\n          permainan seperti jawaban sementara dan statistik. Silahkan gunakan\n          browser lain untuk pengalaman yang lebih optimal.\n        </div>\n      )}\n      <div className=\"border-b border-b-gray-500  relative text-gray-500\">\n        <h1\n          className=\"uppercase text-4xl dark:text-gray-200 text-gray-900 font-bold w-max mx-auto relative z-10 mb-2\"\n          style={{ letterSpacing: 4 }}\n        >\n          {customHeading ?? \"Katla\"}\n        </h1>\n        <div className=\"absolute flex flex-row items-center justify-between inset-0\">\n          <div className=\"flex space-x-2\">\n            <button\n              onClick={onShowHelp}\n              title=\"Bantuan\"\n              aria-label=\"Pengaturan\"\n              style={{\n                visibility: onShowHelp ? \"visible\" : \"hidden\",\n                height: 24,\n              }}\n              tabIndex={-1}\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                width=\"24\"\n              >\n                <path\n                  fill=\"currentColor\"\n                  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\"\n                ></path>\n              </svg>\n            </button>\n            <div className=\"relative flex\">\n              {onSendEmoji && <EmojiSelector onSendEmoji={onSendEmoji} />}\n            </div>\n          </div>\n          <div className=\"flex gap-2\">\n            <button\n              onClick={onShowStats}\n              title=\"Statistik\"\n              aria-label=\"Statistik\"\n              style={{ visibility: onShowStats ? \"visible\" : \"hidden\" }}\n              tabIndex={-1}\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                width=\"24\"\n              >\n                <path\n                  fill=\"currentColor\"\n                  d=\"M16,11V3H8v6H2v12h20V11H16z M10,5h4v14h-4V5z M4,11h4v8H4V11z M20,19h-4v-6h4V19z\"\n                ></path>\n              </svg>\n            </button>\n            <button\n              onClick={onShowSettings}\n              title=\"Pengaturan\"\n              aria-label=\"Pengaturan\"\n              style={{ visibility: onShowSettings ? \"visible\" : \"hidden\" }}\n              tabIndex={-1}\n            >\n              <svg\n                viewBox=\"0 0 24 24\"\n                width=\"24\"\n                height=\"24\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                fill=\"none\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              >\n                <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line>\n                <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>\n              </svg>\n            </button>\n          </div>\n        </div>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "components/HeadingWithNum.tsx",
    "content": "interface Props {\n  num: string | number | null;\n  enableLiarMode?: boolean;\n}\n\nexport default function HeadingWithNum(props: Props) {\n  const [_, mm, dd] = new Date().toISOString().split(\"T\")[0].split(\"-\");\n  const isIndonesiaIndependenceDay = mm === \"08\" && dd === \"17\";\n  let customNumClass = \"\";\n  if (isIndonesiaIndependenceDay) {\n    customNumClass = \"text-white bg-red-500 p-1\";\n  }\n\n  return (\n    <span>\n      {props.enableLiarMode ? \"Katlie\" : <><span style={{ color: \"#EE2A35\"}}>K</span>at<span style={{ color: \"#009736\"}}>la</span></>}\n      {props.num && (\n        <sup\n          className={`-top-4 tracking-tight ${customNumClass}`}\n          style={{ fontSize: \"45%\" }}\n        >\n          #{props.num}\n        </sup>\n      )}\n    </span>\n  );\n}\n"
  },
  {
    "path": "components/HelpModal.tsx",
    "content": "import { memo } from \"react\";\nimport Modal from \"./Modal\";\nimport Tile from \"./Tile\";\n\ninterface Props {\n  isOpen: boolean;\n  onClose: () => void;\n  isLiveMode?: boolean;\n}\n\nexport default memo(HelpModal);\n\nfunction HelpModal(props: Props) {\n  const { isOpen, onClose, isLiveMode = false } = props;\n  return (\n    <Modal isOpen={isOpen} onClose={onClose}>\n      <Modal.Title>Cara Bermain</Modal.Title>\n      <div className=\"text-sm\">\n        <p className=\"mb-2\">\n          Tebak <strong className=\"uppercase\">Katla</strong> dalam 6 kesempatan.\n          {isLiveMode ? (\n            <p className=\"my-2\">\n              Mainkan bersama teman-temanmu (maksimum 10 orang). Setelah\n              permainan selesai, babak baru akan otomatis dimulai dalam 5 detik.\n            </p>\n          ) : (\n            <span>1 hari ada 1 kata rahasia.</span>\n          )}\n        </p>\n        <p className=\"mb-2\">\n          Setiap tebakan harus merupakan kata valid 5 huruf sesuai KBBI. Tekan\n          tombol ENTER untuk mengirimkan jawaban\n        </p>\n        <p className=\"mb-2\">\n          Setelah jawaban dikirimkan, warna kotak akan berubah untuk menunjukkan\n          seberapa dekat tebakanmu dari kata rahasia\n        </p>\n        <hr className=\"dark:border-gray-700 border-gray-500 mb-4\" />\n        <strong className=\"text-lg mb-4 block\">Contoh</strong>\n        <div\n          className=\"grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2\"\n          style={{ aspectRatio: \"6 / 1\" }}\n        >\n          {\"semua\".split(\"\").map((char, i) => {\n            return (\n              <Tile\n                key={i}\n                char={char}\n                state={char === \"s\" ? \"c\" : null}\n                delay={0}\n              />\n            );\n          })}\n        </div>\n        <div className=\"mb-4\">\n          Huruf <strong>S</strong> ada dan posisinya sudah tepat\n        </div>\n        <div\n          className=\"grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2\"\n          style={{ aspectRatio: \"6 / 1\" }}\n        >\n          {\"kasur\".split(\"\").map((char, i) => {\n            return (\n              <Tile\n                key={i}\n                char={char}\n                state={char === \"a\" ? \"e\" : null}\n                delay={0}\n              />\n            );\n          })}\n        </div>\n        <div className=\"mb-4\">\n          Huruf <strong>A</strong> ada namun posisinya belum tepat\n        </div>\n        <div\n          className=\"grid grid-cols-5 grid-rows-1 gap-1.5 w-64 mb-2\"\n          style={{ aspectRatio: \"6 / 1\" }}\n        >\n          {\"duduk\".split(\"\").map((char, i) => {\n            return (\n              <Tile\n                key={i}\n                char={char}\n                state={char === \"k\" ? \"w\" : null}\n                delay={0}\n              />\n            );\n          })}\n        </div>\n        <div className=\"mb-4\">\n          Tidak ada huruf <strong>K</strong> di kata rahasia\n        </div>\n        <hr className=\"dark:border-gray-700 border-gray-500 mb-4\" />\n        {isLiveMode ? null : (\n          <p className=\"font-semibold\">\n            Akan ada <strong className=\"uppercase\">Katla</strong> baru setiap\n            hari!\n          </p>\n        )}\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "components/Keyboard.tsx",
    "content": "import { MutableRefObject, useEffect, useRef } from \"react\";\n\nimport KeyboardButton from \"./KeyboardButton\";\nimport { AnswerState, GameState } from \"../utils/types\";\nimport { decode } from \"../utils/codec\";\n\ninterface Props {\n  onPressChar: (char: string) => void;\n  onBackspace: () => void;\n  onSubmit: () => void;\n  gameState: GameState;\n  hash: string;\n  isAnimating: MutableRefObject<boolean>;\n}\n\nexport default function Keyboard(props: Props) {\n  const { onPressChar, onBackspace, onSubmit, gameState, hash, isAnimating } =\n    props;\n  const answer = decode(hash);\n  const usedChars = new Set(\n    gameState.answers\n      .slice(0, gameState.attempt)\n      .map((answer) => answer.split(\"\"))\n      .flat()\n  );\n\n  const correctChars = new Set();\n  gameState.answers.forEach((userAnswer, i) => {\n    if (i < gameState.attempt) {\n      userAnswer.split(\"\").forEach((char, j) => {\n        if (answer[j] === char) {\n          correctChars.add(char);\n        }\n      });\n    }\n  });\n\n  function getKeyboardState(char: string): AnswerState {\n    let state = null;\n    if (correctChars.has(char)) {\n      state = \"c\";\n    } else if (usedChars.has(char) && answer.includes(char)) {\n      state = \"e\";\n    } else if (usedChars.has(char)) {\n      state = \"w\";\n    }\n\n    return gameState.enableLiarMode ? null : state;\n  }\n\n  const pressed = useRef(null);\n  useEffect(() => {\n    function handleKeydown(e: KeyboardEvent) {\n      if (gameState.attempt === 6) {\n        return;\n      }\n\n      const currentText = gameState.answers[gameState.attempt];\n      if (\n        pressed.current === true &&\n        e.key === currentText[currentText.length - 1]\n      ) {\n        return;\n      }\n\n      if (isAnimating.current) {\n        return;\n      }\n\n      pressed.current = true;\n      if (e.key === \"Backspace\") {\n        onBackspace();\n      } else if (e.key === \"Enter\") {\n        // prevent modal to be opened when pressing enter\n        e.preventDefault();\n        onSubmit();\n      } else if (gameState.enableFreeEdit && e.key === \"_\") {\n        onPressChar(e.key);\n      } else if (/[a-z]/i.test(e.key) && e.key.length === 1) {\n        onPressChar(e.key);\n      }\n    }\n\n    function handleKeyup() {\n      pressed.current = false;\n    }\n\n    document.addEventListener(\"keydown\", handleKeydown);\n    document.addEventListener(\"keyup\", handleKeyup);\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeydown);\n      document.removeEventListener(\"keyup\", handleKeyup);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gameState]);\n\n  return (\n    <div\n      className=\"max-w-lg w-full mx-auto space-y-3 flex flex-col p-4 relative z-auto\"\n      id=\"keyboard\"\n    >\n      <div className=\"flex space-x-2\">\n        {\"qwertyuiop\".split(\"\").map((char) => (\n          <KeyboardButton\n            key={char}\n            state={getKeyboardState(char)}\n            onClick={() => onPressChar(char)}\n          >\n            {char}\n          </KeyboardButton>\n        ))}\n      </div>\n      <div className=\"flex space-x-2\">\n        <div style={{ flex: 0.5 }}></div>\n        {\"asdfghjkl\".split(\"\").map((char) => (\n          <KeyboardButton\n            key={char}\n            state={getKeyboardState(char)}\n            onClick={() => onPressChar(char)}\n          >\n            {char}\n          </KeyboardButton>\n        ))}\n        <div style={{ flex: 0.5 }}></div>\n      </div>\n      <div className=\"flex space-x-2\">\n        <KeyboardButton state={null} onClick={onSubmit} scale={1.5}>\n          Enter\n        </KeyboardButton>\n        {\"zxcvbnm\".split(\"\").map((char) => (\n          <KeyboardButton\n            key={char}\n            state={getKeyboardState(char)}\n            onClick={() => onPressChar(char)}\n          >\n            {char}\n          </KeyboardButton>\n        ))}\n        {gameState.enableFreeEdit && (\n          <KeyboardButton\n            state={null}\n            onClick={() => onPressChar(\"_\")}\n            scale={1.5}\n          >\n            _\n          </KeyboardButton>\n        )}\n        <KeyboardButton state={null} onClick={onBackspace} scale={1.5}>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            width=\"24\"\n          >\n            <path\n              fill=\"currentColor\"\n              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\"\n            ></path>\n          </svg>\n        </KeyboardButton>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/KeyboardButton.tsx",
    "content": "import { ComponentProps, memo } from \"react\";\nimport { AnswerState } from \"../utils/types\";\n\ntype Props = {\n  state: AnswerState;\n  scale?: number;\n} & Omit<ComponentProps<\"button\">, \"className\" | \"style\">;\n\nexport default function KeyboardButton(props: Props) {\n  let color = \"bg-gray-300 text-gray-900 dark:bg-gray-500 dark:text-gray-200\";\n  switch (props.state) {\n    case \"c\":\n      color = \"text-white bg-correct\";\n      break;\n    case \"e\":\n      color = \"text-white bg-exist\";\n      break;\n    case \"w\":\n      color = \"text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700\";\n      break;\n    default:\n  }\n\n  return (\n    <button\n      className={`rounded-md uppercase font-semibold text-sm flex items-center justify-center ${color} select-none`}\n      style={{ minHeight: 48, flex: props.scale ?? 1 }}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "components/Link.tsx",
    "content": "import NextLink from \"next/link\";\nimport { ComponentProps } from \"react\";\n\nexport default function Link(props: ComponentProps<typeof NextLink>) {\n  const { children, ...rest } = props;\n  return (\n    <NextLink {...rest}>\n      <a className=\"color-accent\">{children}</a>\n    </NextLink>\n  );\n}\n"
  },
  {
    "path": "components/LiveStatsModal.tsx",
    "content": "import Modal from \"./Modal\";\nimport Alert from \"./Alert\";\nimport { useMap, useOthers, useSelf } from \"@liveblocks/react\";\n\ninterface Props {\n  isOpen: boolean;\n  onClose: () => void;\n  totalPlay: number;\n}\n\nconst GRAPH_WIDTH_MIN_RATIO = 10;\n\nexport default function LiveStatsModal(props: Props) {\n  const others = useOthers();\n  const self = useSelf();\n  const { isOpen, onClose, totalPlay } = props;\n\n  const users = others\n    .toArray()\n    .concat(self)\n    .filter(Boolean)\n    .sort((a, b) => {\n      return (a.presence?.winCount ?? 0) > (b.presence?.winCount ?? 0) ? -1 : 1;\n    });\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose}>\n      <Modal.Title>Statistik</Modal.Title>\n      <div className=\"w-10/12 mx-auto mb-8\">\n        <h3 className=\"uppercase font-semibold mb-4\">Distribusi Kemenangan</h3>\n        <div>\n          {users.map((user) => {\n            const shouldHighlight = user.connectionId === self.connectionId;\n            const winCount = user.presence?.winCount ?? 0;\n            const ratio =\n              totalPlay === 0\n                ? GRAPH_WIDTH_MIN_RATIO\n                : Math.max((winCount / totalPlay) * 100, GRAPH_WIDTH_MIN_RATIO);\n            const alignment =\n              ratio === GRAPH_WIDTH_MIN_RATIO\n                ? \"justify-center\"\n                : \"justify-end\";\n            const background = shouldHighlight ? \"bg-accent\" : \"bg-gray-500\";\n            return (\n              <div\n                className=\"grid grid-cols-leaderboard h-5 mb-2\"\n                key={user.connectionId}\n              >\n                <div className=\"tabular-nums\">{user.id}</div>\n                <div className=\"w-full h-full pl-1\">\n                  <div\n                    className={`text-right text-white ${background} flex ${alignment} px-2 font-bold`}\n                    style={{ width: ratio + \"%\" }}\n                  >\n                    {winCount}\n                  </div>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "components/Modal.tsx",
    "content": "import { DialogOverlay, DialogContent } from \"@reach/dialog\";\nimport { useEffect, useState, ReactNode, useCallback } from \"react\";\n\nimport { getTotalPlay, isGameFinished } from \"../utils/game\";\nimport { GameStats, Game } from \"../utils/types\";\n\ninterface Props {\n  isOpen: boolean;\n  onClose?: () => void;\n  children: ReactNode;\n}\n\nexport default function Modal(props: Props) {\n  const { isOpen, onClose, children } = props;\n  return (\n    <DialogOverlay\n      isOpen={isOpen}\n      onDismiss={onClose}\n      className=\"fixed inset-0 bg-black bg-opacity-50 overflow-y-auto z-10\"\n    >\n      <DialogContent aria-labelledby=\"dialogTitle\">\n        <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\">\n          <button\n            onClick={onClose}\n            title=\"close\"\n            aria-label=\"close\"\n            className=\"absolute right-4 top-4 text-gray-500\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              width=\"24\"\n            >\n              <path\n                fill=\"currentColor\"\n                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\"\n              ></path>\n            </svg>\n          </button>\n          {children}\n        </div>\n      </DialogContent>\n    </DialogOverlay>\n  );\n}\n\nconst Title = ({ children }) => (\n  <h2 id=\"dialogTitle\" className=\"text-center uppercase font-semibold my-4\">\n    {children}\n  </h2>\n);\nModal.Title = Title;\n\ntype ModalState = \"help\" | \"stats\" | \"settings\";\n\ntype ModalStateReturn = [ModalState, (state: ModalState) => void, () => void];\n\nexport function useModalState(game: Game, stats: GameStats): ModalStateReturn {\n  const [modalState, setModalState] = useState<ModalState | null>(null);\n\n  useEffect(() => {\n    if (!game.ready) {\n      return;\n    }\n\n    // show help screen for first-time player\n    if (\n      getTotalPlay(stats) === 0 &&\n      game.state.attempt === 0 &&\n      game.state.answers[0] === \"\"\n    ) {\n      setModalState(\"help\");\n    }\n    // show stats screen if user already finished playing current session\n    else if (isGameFinished(game)) {\n      setModalState(\"stats\");\n    }\n\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [game.ready]);\n\n  useEffect(() => {\n    // reset modal every new game\n    setModalState(null);\n  }, [game.num]);\n\n  const resetModalState = useCallback(() => {\n    setModalState(null);\n  }, []);\n\n  return [modalState, setModalState, resetModalState];\n}\n"
  },
  {
    "path": "components/SettingsModal.tsx",
    "content": "import { useTheme } from \"next-themes\";\n\nimport Link from \"./Link\";\nimport Modal from \"./Modal\";\nimport Alert from \"./Alert\";\n\nimport {\n  GAME_STATE_KEY,\n  GAME_STATS_KEY,\n  INVALID_WORDS_KEY,\n  LAST_HASH_KEY,\n  LAST_SESSION_RESET_KEY,\n} from \"../utils/constants\";\nimport LocalStorage from \"../utils/browser\";\nimport { ForcedResult, Game, LiveConfig } from \"../utils/types\";\nimport { shareInviteLink } from \"../utils/liveGame\";\n\ninterface Props {\n  isOpen: boolean;\n  onClose: () => void;\n  game: Game;\n  liveConfig?: LiveConfig;\n}\n\nexport default function SettingsModal(props: Props) {\n  const { game, isOpen, onClose, liveConfig } = props;\n  const { resolvedTheme, setTheme } = useTheme();\n  const showLiarMode = game.num === 71;\n  const correctColor = game.state.enableHighContrast ? \"biru\" : \"hijau\";\n  const incorrectColor = game.state.enableHighContrast ? \"oranye\" : \"kuning\";\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose}>\n      <Modal.Title>Pengaturan</Modal.Title>\n      {game.num !== -1 && (\n        <Switch\n          title=\"Mode Sulit\"\n          subtitle=\"Semua petunjuk dari jawaban sebelumnya harus digunakan\"\n          onlyEnableOnFirstAttempt\n          attempt={game.state.attempt}\n          active={game.state.enableHardMode}\n          onChange={(enableHardMode) => {\n            game.setState({ ...game.state, enableHardMode });\n          }}\n        />\n      )}\n      <Switch\n        title=\"Mode Gelap\"\n        active={resolvedTheme === \"dark\"}\n        onChange={(active) => {\n          setTheme(active ? \"dark\" : \"light\");\n        }}\n      />\n      <Switch\n        title=\"Mode Buta Warna\"\n        subtitle=\"Warna kontras tinggi\"\n        active={game.state.enableHighContrast}\n        onChange={(enableHighContrast) => {\n          game.setState({ ...game.state, enableHighContrast });\n        }}\n      />\n      <Switch\n        title=\"Mode Edit Bebas\"\n        subtitle=\"Hapus huruf di kotak manapun dan lewati kotak dengan karakter '_'\"\n        isExperimental\n        active={game.state.enableFreeEdit}\n        onChange={(enableFreeEdit) => {\n          game.setState({ ...game.state, enableFreeEdit });\n        }}\n      />\n      {showLiarMode && (\n        <Switch\n          title=\"Mode Bohong\"\n          subtitle={`Setiap baris terdapat 1 kotak acak yang belum tentu mencerminkan petunjuk seharusnya (misal ${correctColor} menjadi ${incorrectColor})`}\n          onlyEnableOnFirstAttempt\n          attempt={game.state.attempt}\n          active={game.state.enableLiarMode}\n          onChange={(enableLiarMode) => {\n            game.setState({\n              ...game.state,\n              enableLiarMode,\n              lieBoxes: generateLieBoxes(),\n            });\n          }}\n        />\n      )}\n      {liveConfig ? (\n        liveConfig.isHost ? (\n          <AdminTools config={liveConfig} game={game} onClose={onClose} />\n        ) : (\n          <PlayerTools config={liveConfig} />\n        )\n      ) : (\n        <AdditionalInformation />\n      )}\n    </Modal>\n  );\n}\n\ninterface AdminToolsProps {\n  game: Game;\n  onClose: () => void;\n  config: LiveConfig;\n}\n\nfunction AdminTools(props: AdminToolsProps) {\n  const { game, onClose, config } = props;\n\n  function handleReset() {\n    game.resetState();\n    onClose();\n  }\n\n  function handleInvite() {\n    shareInviteLink(config, onClose);\n  }\n\n  return (\n    <div>\n      <h4 className=\"text-center uppercase font-semibold my-4\">Mode Lawan</h4>\n      <button className=\"color-accent block mb-2\" onClick={handleInvite}>\n        Ajak pemain\n      </button>\n      <button className=\"color-accent block mb-2\" onClick={handleReset}>\n        Mulai dari awal\n      </button>\n    </div>\n  );\n}\n\ninterface PlayerToolsProps {\n  config: LiveConfig;\n}\n\nfunction PlayerTools(props: PlayerToolsProps) {\n  const { config } = props;\n\n  function handleInvite() {\n    shareInviteLink(config);\n  }\n\n  return (\n    <div>\n      <h4 className=\"text-center uppercase font-semibold my-4\">\n        Pengaturan Mode Lawan\n      </h4>\n      <button className=\"color-accent\" onClick={handleInvite}>\n        Ajak pemain\n      </button>\n    </div>\n  );\n}\n\nfunction AdditionalInformation() {\n  function handleReset() {\n    LocalStorage.removeItem(GAME_STATE_KEY);\n    LocalStorage.removeItem(GAME_STATS_KEY);\n    LocalStorage.removeItem(INVALID_WORDS_KEY);\n    LocalStorage.removeItem(LAST_HASH_KEY);\n    LocalStorage.setItem(\n      LAST_SESSION_RESET_KEY,\n      new Date().getTime().toString()\n    );\n    window.location.reload();\n  }\n\n  return (\n    <>\n      <h4 className=\"text-center uppercase font-semibold my-4\">Informasi</h4>\n      <p className=\"mb-4\">\n        <strong>Katla</strong> merupakan <s>imitasi</s> adaptasi dari{\" \"}\n        <a\n          href=\"https://www.powerlanguage.co.uk/wordle/\"\n          className=\"color-accent\"\n        >\n          Wordle\n        </a>\n      </p>\n      <p className=\"mb-4\">\n        Kamu bisa melihat daftar kata yang telah digunakan sebelumnya di dalam{\" \"}\n        <Link href=\"/arsip\">Arsip</Link>\n      </p>\n      <div>\n        <h2 className=\"text-xl font-semibold\">Terdapat Masalah?</h2>\n        <Link href=\"/bantuan\">Bantuan</Link>\n        <span> atau </span>\n        <button onClick={handleReset} className=\"color-accent\">\n          reset sesi sekarang\n        </button>\n      </div>\n    </>\n  );\n}\n\ninterface SwitchProps {\n  active: boolean;\n  title: string;\n  subtitle?: string;\n  onlyEnableOnFirstAttempt?: boolean;\n  attempt?: number;\n  isExperimental?: boolean;\n  onChange: (active: boolean) => void;\n}\n\nfunction Switch(props: SwitchProps) {\n  const {\n    title,\n    subtitle,\n    isExperimental,\n    active,\n    onChange,\n    onlyEnableOnFirstAttempt,\n    attempt,\n  } = props;\n\n  const disabled = onlyEnableOnFirstAttempt && attempt > 0;\n  const disabledText = `${title} hanya dapat diganti di awal permainan`;\n  const warningText = onlyEnableOnFirstAttempt\n    ? `Hanya dapat diganti di awal permainan`\n    : isExperimental\n    ? \"Masih dalam tahap uji coba\"\n    : \"\";\n\n  function handleClick() {\n    if (disabled) {\n      Alert.show(disabledText, { id: \"disabled\" });\n      return;\n    }\n\n    onChange(!active);\n  }\n\n  return (\n    <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\">\n      <div className=\"flex flex-col\">\n        <p className=\"mb-0\">\n          {title}{\" \"}\n          {warningText && (\n            <span className=\"text-xs text-yellow-600 dark:text-yellow-400 inline-block\">\n              {\"(\" + warningText + \")\"}\n            </span>\n          )}\n        </p>\n        {subtitle && <span className=\"text-xs text-gray-500\">{subtitle}</span>}\n      </div>\n      <button\n        className={`${\n          active ? \"bg-correct\" : \"bg-gray-500\"\n        } w-10 h-6 flex items-center rounded-full px-1 flex-shrink-0`}\n        onClick={handleClick}\n        style={{ cursor: disabled ? \"not-allowed\" : \"pointer\" }}\n      >\n        <div\n          className={`bg-white w-4 h-4 rounded-full shadow-md transform transition ${\n            active ? \"translate-x-4\" : \"\"\n          }`}\n        ></div>\n      </button>\n    </div>\n  );\n}\n\nfunction generateLieBoxes(): ForcedResult[] {\n  // only 5 because we want the last one to show the real answer\n  return Array(5)\n    .fill(0)\n    .map(() => {\n      const isExist = Math.random() > 0.35;\n      const isCorrect = Math.random() > 0.5;\n      const col = Math.floor(Math.random() * 5);\n      return isExist ? (isCorrect ? [col, \"c\"] : [col, \"e\"]) : [col, \"w\"];\n    });\n}\n"
  },
  {
    "path": "components/SponsorshipFooter.tsx",
    "content": "export default function SponsorshipFooter() {\n  return (\n    <footer className=\"text-sm pb-4\">\n      Built with{\" \"}\n      <a\n        className=\"font-bold\"\n        href=\"https://vercel.com?utm_source=katla&utm_campaign=oss\"\n      >\n        Vercel\n      </a>\n    </footer>\n  );\n}\n"
  },
  {
    "path": "components/StatsModal.tsx",
    "content": "import useSWR from \"swr\";\nimport { LegacyRef, useEffect, useRef, useState } from \"react\";\nimport { useTheme } from \"next-themes\";\n\nimport Modal from \"./Modal\";\n\nimport { AnswerState, Game, GameStats } from \"../utils/types\";\nimport { decode } from \"../utils/codec\";\nimport fetcher from \"../utils/fetcher\";\nimport { pad0 } from \"../utils/formatter\";\nimport {\n  useRemainingTime,\n  getTotalPlay,\n  getTotalWin,\n  getAnswerStates,\n} from \"../utils/game\";\nimport { checkNativeShareSupport, shareText } from \"../utils/browser\";\nimport { isEidMessage } from \"../utils/message\";\n\ninterface Props {\n  isOpen: boolean;\n  onClose: () => void;\n  game: Game;\n  stats: GameStats;\n  remainingTime?: ReturnType<typeof useRemainingTime>;\n}\n\ndeclare global {\n  interface Window {\n    adsbygoogle: { loaded: boolean; push: (v: unknown) => void };\n  }\n}\n\nconst GRAPH_WIDTH_MIN_RATIO = 10;\n\nexport default function StatsModal(props: Props) {\n  const { isOpen, onClose, game, stats } = props;\n  const { resolvedTheme } = useTheme();\n  const isAnswered =\n    game.state.answers[game.state.attempt - 1] === decode(game.hash);\n  const [canShareImage, setCanShareImage] = useState(false);\n  const [showAnswersCheckbox, setShowAnswersCheckbox] = useState(false);\n\n  useEffect(() => {\n    const canShareImage =\n      checkNativeShareSupport() && typeof navigator.canShare === \"function\";\n    setCanShareImage(canShareImage);\n\n    if (!canShareImage) {\n      // preload image share logic\n      import(\"file-saver\");\n    }\n  }, []);\n\n  const answer = decode(game.hash);\n  const showShare =\n    game.state.attempt === 6 ||\n    game.state.answers[game.state.attempt - 1] === answer;\n  const totalWin = getTotalWin(stats);\n  const totalPlay = getTotalPlay(stats);\n  const title = game.state.enableLiarMode ? \"Katlie\" : \"Katla\";\n\n  function generateText() {\n    const hardModeMarker = game.state.enableHardMode ? \"*\" : \"\";\n    const score =\n      game.state.answers[game.state.attempt - 1] === answer\n        ? game.state.attempt\n        : \"X\";\n    let text = `${title} ${game.num} ${score}/6${hardModeMarker}\\n\\n`;\n\n    game.state.answers.filter(Boolean).forEach((userAnswer) => {\n      const answerEmojis = getAnswerStates(userAnswer, answer).map((state) => {\n        switch (state) {\n          case \"c\":\n            return game.state.enableHighContrast ? \"🟧\" : \"🟩\";\n          case \"e\":\n            return game.state.enableHighContrast ? \"🟦\" : \"🟨\";\n          case \"w\":\n            return resolvedTheme === \"dark\" ? \"⬛\" : \"⬜️\";\n        }\n      });\n      text += `${answerEmojis.join(\"\")}\\n`;\n    });\n\n    if (\n      game.num === 102 &&\n      isEidMessage(game.state.answers[game.state.attempt - 1])\n    ) {\n      text = `🙏🙏🙏🙏🙏\\n\\n`;\n      text += `⬛⬛🟩⬛⬛\\n`;\n      text += `⬛🟩🟨🟩⬛\\n`;\n      text += `🟩🟨🟩🟨🟩\\n`;\n      text += `⬛🟩🟨🟩⬛\\n`;\n      text += `⬛⬛🟩⬛⬛\\n`;\n      text += \"\\n\" + window.location.href;\n      return text;\n    }\n\n    text += \"\\n\" + window.location.href;\n    return text;\n  }\n\n  function handleShare() {\n    shareText(generateText(), { cb: onClose });\n  }\n\n  async function handleShareImage() {\n    const canvas = document.createElement(\"canvas\");\n    canvas.height = 1400;\n    canvas.width = 900;\n\n    const ctx = canvas.getContext(\"2d\");\n    ctx.fillStyle = resolvedTheme === \"dark\" ? \"#111827\" : \"#ffffff\";\n    ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n    const gap = 10;\n    const paddingH = 200;\n    const paddingT = 400;\n    const size = (canvas.width - paddingH * 2 - gap * 4) / 5;\n\n    const score =\n      game.state.answers[game.state.attempt - 1] === answer\n        ? game.state.attempt\n        : \"X\";\n    const hardModeMarker = game.state.enableHardMode ? \"*\" : \"\";\n\n    let answers = game.state.answers.slice(0, game.state.attempt);\n    let answerStates: AnswerState[][] = answers.map((answer) => {\n      return getAnswerStates(answer, decode(game.hash));\n    });\n    let text = `${title} ${game.num} ${score}/6${hardModeMarker}\\n\\n`;\n\n    if (\n      game.num === 102 &&\n      isEidMessage(game.state.answers[game.state.attempt - 1])\n    ) {\n      text = `🙏🙏🙏🙏🙏🙏`;\n      answers = [\"mohon\", \"maaf\", \"lahir\", \"dan\", \"batin\"];\n      answerStates = [\n        [\"w\", \"w\", \"c\", \"w\", \"w\"],\n        [\"w\", \"c\", \"e\", \"c\", \"w\"],\n        [\"c\", \"e\", \"c\", \"e\", \"c\"],\n        [\"w\", \"c\", \"e\", \"c\", \"w\"],\n        [\"w\", \"w\", \"c\", \"w\", \"w\"],\n      ];\n    }\n\n    ctx.font = \"bold 42px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.fillStyle = resolvedTheme === \"dark\" ? \"#ffffff\" : \"#111827\";\n    ctx.fillText(text, canvas.width / 2, 300);\n    ctx.font = \"32px sans-serif\";\n    ctx.fillText(\"katla.vercel.app\", canvas.width / 2, canvas.height - 150);\n\n    answerStates.forEach((states, y) => {\n      const answer = answers[y];\n      states.forEach((state, x) => {\n        if (state === \"c\") {\n          ctx.fillStyle = game.state.enableHighContrast ? \"#f5793a\" : \"#15803d\";\n        } else if (state === \"e\") {\n          ctx.fillStyle = game.state.enableHighContrast ? \"#85c0f9\" : \"#ca8a04\";\n        } else {\n          ctx.fillStyle = resolvedTheme === \"dark\" ? \"#374151\" : \"#6b7280\";\n        }\n\n        const marginH = gap * x;\n        const marginV = gap * y;\n        const rectX = paddingH + x * size + marginH;\n        const rectY = paddingT + y * size + marginV;\n\n        ctx.beginPath();\n        ctx.rect(rectX, rectY, size, size);\n        ctx.fill();\n\n        if (showAnswersCheckbox) {\n          ctx.font = \"bold 54px sans-serif\";\n          ctx.fillStyle = \"#ffffff\";\n          ctx.fillText(\n            answer[x]?.toUpperCase() ?? \"\",\n            rectX + size / 2,\n            rectY + size / 1.5\n          );\n        }\n      });\n    });\n\n    const dataURL = canvas.toDataURL();\n    const blob = await (await fetch(dataURL)).blob();\n\n    if (canShareImage) {\n      const imageName = showAnswersCheckbox\n        ? `katla-${game.num}-with-answers.jpg`\n        : `katla-${game.num}.jpg`;\n      const shareData = {\n        files: [\n          new File([blob], imageName, {\n            type: \"image/jpeg\",\n            lastModified: new Date().getTime(),\n          }),\n        ],\n      };\n      navigator.share(shareData).catch(() => {});\n    } else {\n      const { saveAs } = await import(\"file-saver\").then((mod) => mod.default);\n      const imageName = showAnswersCheckbox\n        ? `katla-${game.num}-with-answers`\n        : `katla-${game.num}`;\n      saveAs(blob, imageName);\n    }\n  }\n\n  function handleShareToTwitter() {\n    const text = generateText();\n    const encodeURI = text.replace(/\\n/g, \"%0A\");\n    const shareToTwitter = `https://twitter.com/intent/tweet?text=${encodeURI}`;\n    window.open(shareToTwitter, \"_blank\");\n  }\n\n  const { fail: _, ...distribution } = stats.distribution;\n  const maxDistribution = Math.max(...Object.values(distribution));\n\n  const [isAdUnitRendered, setIsAdUnitRendered] = useState(false);\n  const adsByGooglePushedRef = useRef(false);\n  const adUnitRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!isOpen || !isAdUnitRendered || adsByGooglePushedRef.current) return;\n\n    const adUnit = adUnitRef.current;\n    adUnit.innerHTML = `<!-- stats-ads -->\n    <ins class=\"adsbygoogle\"\n      style=\"display:block\"\n      data-ad-client=\"ca-pub-3081263972680635\"\n      data-ad-slot=\"2576511153\"\n      data-ad-format=\"auto\"\n      data-full-width-responsive=\"true\"></ins>`;\n\n    try {\n      // @ts-ignore\n      window.adsbygoogle = (window.adsbygoogle || []).push({});\n      adsByGooglePushedRef.current = true;\n    } catch (err) {\n      // ignore\n    }\n  }, [isOpen, isAdUnitRendered]);\n\n  const adUnitRefCallback: LegacyRef<HTMLDivElement> = (element) => {\n    adUnitRef.current = element;\n    setIsAdUnitRendered(!!element);\n  };\n\n  return (\n    <Modal isOpen={isOpen} onClose={onClose}>\n      <Modal.Title>Statistik</Modal.Title>\n      <div className=\"grid grid-rows-1 grid-cols-4 text-center w-3/4 space-x-1 mx-auto mb-8\">\n        <div>\n          <div className=\"text-md sm:text-xl lg:text-3xl\">{totalPlay}</div>\n          <div className=\"text-xs md:text-sm break-word\">Dimainkan</div>\n        </div>\n        <div>\n          <div className=\"text-md sm:text-xl lg:text-3xl\">\n            {totalPlay === 0 ? 0 : Math.round((totalWin / totalPlay) * 100)}\n          </div>\n          <div className=\"text-xs md:text-sm break-word\">% Menang</div>\n        </div>\n        <div>\n          <div className=\"text-md sm:text-xl lg:text-3xl\">\n            {stats.currentStreak}\n          </div>\n          <div className=\"text-xs md:text-sm break-word\">Runtunan saat ini</div>\n        </div>\n        <div>\n          <div className=\"text-md sm:text-xl lg:text-3xl\">\n            {stats.maxStreak}\n          </div>\n          <div className=\"text-xs md:text-sm break-word\">Runtunan maksimum</div>\n        </div>\n      </div>\n      <div className=\"w-10/12 mx-auto mb-8\">\n        <h3 className=\"uppercase font-semibold mb-4\">Distribusi Tebakan</h3>\n        {Array(6)\n          .fill(\"\")\n          .map((_, i) => {\n            const shouldHighlight = isAnswered && i === game.state.attempt - 1;\n            const ratio =\n              totalWin === 0\n                ? GRAPH_WIDTH_MIN_RATIO\n                : Math.max(\n                    (Number(stats.distribution[i + 1]) / maxDistribution) * 100,\n                    GRAPH_WIDTH_MIN_RATIO\n                  );\n            const alignment =\n              ratio === GRAPH_WIDTH_MIN_RATIO\n                ? \"justify-center\"\n                : \"justify-end\";\n            const background = shouldHighlight ? \"bg-accent\" : \"bg-gray-500\";\n            return (\n              <div className=\"flex h-5 mb-2\" key={i}>\n                <div className=\"tabular-nums\">{i + 1}</div>\n                <div className=\"w-full h-full pl-1\">\n                  <div\n                    className={`text-right text-white ${background} flex ${alignment} px-2 font-bold`}\n                    style={{ width: ratio + \"%\" }}\n                  >\n                    {stats.distribution[i + 1]}\n                  </div>\n                </div>\n              </div>\n            );\n          })}\n      </div>\n      <div className=\"w-10/12 mx-auto mb-2\" ref={adUnitRefCallback} />\n      {showShare && (\n        <>\n          <WordDefinition answer={answer} />\n          <div className=\"flex items-center justify-between w-3/4 m-auto my-8 space-x-2\">\n            {props.remainingTime ? (\n              <TimeCounter time={props.remainingTime} />\n            ) : (\n              <div />\n            )}\n            <div className=\"bg-gray-400\" style={{ width: 1 }}></div>\n            <div className=\"flex flex-col space-y-4 text-white\">\n              <button\n                onClick={handleShare}\n                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\"\n              >\n                <div>Share</div>\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  height=\"24\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"24\"\n                >\n                  <path\n                    fill=\"currentColor\"\n                    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\"\n                  ></path>\n                </svg>\n              </button>\n\n              <button\n                onClick={handleShareImage}\n                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\"\n              >\n                <div>Image</div>\n                {canShareImage ? (\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    height=\"24\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"24\"\n                  >\n                    <path\n                      fill=\"currentColor\"\n                      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\"\n                    ></path>\n                  </svg>\n                ) : (\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    width=\"24\"\n                    height=\"24\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  >\n                    <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path>\n                    <polyline points=\"7 10 12 15 17 10\"></polyline>\n                    <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>\n                  </svg>\n                )}\n              </button>\n\n              <label className=\"flex items-center gap-2 text-xs dark:text-gray-400 text-gray-600\">\n                <input\n                  type=\"checkbox\"\n                  checked={showAnswersCheckbox}\n                  onChange={(e) => setShowAnswersCheckbox(e.target.checked)}\n                />\n                <span>Sertakan jawaban pada gambar</span>\n              </label>\n\n              <button\n                onClick={handleShareToTwitter}\n                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\"\n                style={{ backgroundColor: \"#00acee\" }}\n              >\n                <div>Tweet</div>\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"#ffffff\"\n                >\n                  <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\" />\n                </svg>\n              </button>\n            </div>\n          </div>\n        </>\n      )}\n    </Modal>\n  );\n}\n\nfunction WordDefinition({ answer }) {\n  const { data = [] } = useSWR(`/api/define/${answer}`, (path) => {\n    return fetcher(path, {\n      headers: {\n        Authorization: `token ${process.env.NEXT_PUBLIC_DEFINE_TOKEN}`,\n      },\n    });\n  });\n\n  return (\n    <div className=\"w-10/12 mx-auto mb-8\">\n      <h3 className=\"uppercase font-semibold\">Katla hari ini</h3>\n      <p className=\"text-xs mb-2 dark:text-gray-400 text-gray-600\">\n        Mohon untuk tetap dirahasiakan, semua orang mendapatkan kata yang sama\n        🙏\n      </p>\n      <p>\n        <strong>{answer}</strong>\n        {data.length > 0 ? (\n          data.length === 1 ? (\n            `: ${data[0]}`\n          ) : (\n            <ul className=\"text-sm\">\n              {data.map((d, i) => (\n                <li className=\" list-outside list-disc ml-6\" key={i}>\n                  {d}\n                </li>\n              ))}\n            </ul>\n          )\n        ) : null}\n      </p>\n      <a\n        className=\"color-accent text-sm\"\n        href={`https://kbbi.kemdikbud.go.id/entri/${answer}`}\n      >\n        Lihat di KBBI\n      </a>\n    </div>\n  );\n}\n\nfunction TimeCounter({ time }: { time: ReturnType<typeof useRemainingTime> }) {\n  const remainingTime = `${time.hours}:${pad0(time.minutes)}:${pad0(\n    time.seconds\n  )}`;\n\n  return (\n    <div className=\"text-center flex flex-1 flex-col\">\n      <div className=\"font-semibold uppercase text-xs md:text-md\">\n        Katla berikutnya\n      </div>\n      <div className=\"text-xl md:text-4xl\">{remainingTime}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/Tile.tsx",
    "content": "import { CSSProperties, useEffect, useState } from \"react\";\nimport {\n  FLIP_ANIMATION_DELAY_MS,\n  FLIP_ANIMATION_DURATION_MS,\n  SHAKE_ANIMATION_DURATION_MS,\n} from \"../utils/constants\";\nimport { AnswerState } from \"../utils/types\";\n\ninterface Props {\n  char: string;\n  state: AnswerState;\n  isInvalid?: boolean;\n  delay: number;\n  onPress?: () => void;\n}\n\nexport default function Tile(props: Props) {\n  const [background, setBackground] = useState(\"text-gray-700 dark:text-white\");\n  const [animate, setAnimationEnabled] = useState(false);\n  const border =\n    props.char === \" \" ? \"border\" : props.state === null ? \"border-3\" : \"\";\n  const borderColor =\n    props.char === \" \"\n      ? \"dark:border-gray-700 border-gray-400\"\n      : props.state === null\n      ? \"border-gray-500\"\n      : \"\";\n\n  useEffect(() => {\n    if (props.state === null) {\n      return;\n    }\n\n    setAnimationEnabled(true);\n    () => setAnimationEnabled(false);\n  }, [props.state]);\n\n  const style: CSSProperties = {};\n  if (props.isInvalid) {\n    style.animationName = \"shake\";\n    style.animationDuration = `${SHAKE_ANIMATION_DURATION_MS}ms`;\n  }\n\n  if (animate) {\n    style.animationName = \"flip\";\n    style.animationDuration = `${FLIP_ANIMATION_DURATION_MS}ms`;\n    style.animationDelay = `${props.delay}ms`;\n  }\n\n  useEffect(() => {\n    setTimeout(() => {\n      switch (props.state) {\n        case \"c\":\n          setBackground(\"text-white dark:text-gray-200 bg-correct\");\n          break;\n        case \"e\":\n          setBackground(\"text-white bg-exist\");\n          break;\n        case \"w\":\n          setBackground(\n            \"text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700\"\n          );\n          break;\n      }\n    }, props.delay + FLIP_ANIMATION_DELAY_MS);\n  }, [animate, props.state, props.delay]);\n\n  return (\n    <button\n      style={style}\n      className={`rounded-sm uppercase text-center h-full w-full text-dynamic font-bold ${background} flex justify-center items-center ${border} ${borderColor} select-none`}\n      tabIndex={-1}\n      onClick={props.onPress}\n    >\n      {props.char === \"_\" ? \"\" : props.char}\n    </button>\n  );\n}\n"
  },
  {
    "path": "env.dev",
    "content": "NEXT_PUBLIC_DEFINE_TOKEN=\"test\"\n"
  },
  {
    "path": "jest.config.js",
    "content": "/*\n * For a detailed explanation regarding each configuration property and type check, visit:\n * https://jestjs.io/docs/configuration\n */\n\nmodule.exports = {\n  // All imported modules in your tests should be mocked automatically\n  // automock: false,\n\n  // Stop running tests after `n` failures\n  // bail: 0,\n\n  // The directory where Jest should store its cached dependency information\n  // cacheDirectory: \"/private/var/folders/s9/v3c_29w146qcg2v7f_pwmb3c0000gn/T/jest_dx\",\n\n  // Automatically clear mock calls, instances and results before every test\n  clearMocks: true,\n\n  // Indicates whether the coverage information should be collected while executing the test\n  // collectCoverage: false,\n\n  // An array of glob patterns indicating a set of files for which coverage information should be collected\n  // collectCoverageFrom: undefined,\n\n  // The directory where Jest should output its coverage files\n  // coverageDirectory: undefined,\n\n  // An array of regexp pattern strings used to skip coverage collection\n  // coveragePathIgnorePatterns: [\n  //   \"/node_modules/\"\n  // ],\n\n  // Indicates which provider should be used to instrument code for coverage\n  coverageProvider: \"v8\",\n\n  // A list of reporter names that Jest uses when writing coverage reports\n  // coverageReporters: [\n  //   \"json\",\n  //   \"text\",\n  //   \"lcov\",\n  //   \"clover\"\n  // ],\n\n  // An object that configures minimum threshold enforcement for coverage results\n  // coverageThreshold: undefined,\n\n  // A path to a custom dependency extractor\n  // dependencyExtractor: undefined,\n\n  // Make calling deprecated APIs throw helpful error messages\n  // errorOnDeprecated: false,\n\n  // Force coverage collection from ignored files using an array of glob patterns\n  // forceCoverageMatch: [],\n\n  // A path to a module which exports an async function that is triggered once before all test suites\n  // globalSetup: undefined,\n\n  // A path to a module which exports an async function that is triggered once after all test suites\n  // globalTeardown: undefined,\n\n  // A set of global variables that need to be available in all test environments\n  // globals: {},\n\n  // 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.\n  // maxWorkers: \"50%\",\n\n  // An array of directory names to be searched recursively up from the requiring module's location\n  // moduleDirectories: [\n  //   \"node_modules\"\n  // ],\n\n  // An array of file extensions your modules use\n  // moduleFileExtensions: [\n  //   \"js\",\n  //   \"jsx\",\n  //   \"ts\",\n  //   \"tsx\",\n  //   \"json\",\n  //   \"node\"\n  // ],\n\n  // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module\n  // moduleNameMapper: {},\n\n  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader\n  // modulePathIgnorePatterns: [],\n\n  // Activates notifications for test results\n  // notify: false,\n\n  // An enum that specifies notification mode. Requires { notify: true }\n  // notifyMode: \"failure-change\",\n\n  // A preset that is used as a base for Jest's configuration\n  // preset: undefined,\n\n  // Run tests from one or more projects\n  // projects: undefined,\n\n  // Use this configuration option to add custom reporters to Jest\n  // reporters: undefined,\n\n  // Automatically reset mock state before every test\n  // resetMocks: false,\n\n  // Reset the module registry before running each individual test\n  // resetModules: false,\n\n  // A path to a custom resolver\n  // resolver: undefined,\n\n  // Automatically restore mock state and implementation before every test\n  // restoreMocks: false,\n\n  // The root directory that Jest should scan for tests and modules within\n  // rootDir: undefined,\n\n  // A list of paths to directories that Jest should use to search for files in\n  // roots: [\n  //   \"<rootDir>\"\n  // ],\n\n  // Allows you to use a custom runner instead of Jest's default test runner\n  // runner: \"jest-runner\",\n\n  // The paths to modules that run some code to configure or set up the testing environment before each test\n  // setupFiles: [],\n\n  // A list of paths to modules that run some code to configure or set up the testing framework before each test\n  // setupFilesAfterEnv: [],\n\n  // The number of seconds after which a test is considered as slow and reported as such in the results.\n  // slowTestThreshold: 5,\n\n  // A list of paths to snapshot serializer modules Jest should use for snapshot testing\n  // snapshotSerializers: [],\n\n  // The test environment that will be used for testing\n  // testEnvironment: \"jest-environment-node\",\n\n  // Options that will be passed to the testEnvironment\n  // testEnvironmentOptions: {},\n\n  // Adds a location field to test results\n  // testLocationInResults: false,\n\n  // The glob patterns Jest uses to detect test files\n  // testMatch: [\n  //   \"**/__tests__/**/*.[jt]s?(x)\",\n  //   \"**/?(*.)+(spec|test).[tj]s?(x)\"\n  // ],\n\n  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped\n  // testPathIgnorePatterns: [\n  //   \"/node_modules/\"\n  // ],\n\n  // The regexp pattern or array of patterns that Jest uses to detect test files\n  // testRegex: [],\n\n  // This option allows the use of a custom results processor\n  // testResultsProcessor: undefined,\n\n  // This option allows use of a custom test runner\n  // testRunner: \"jest-circus/runner\",\n\n  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href\n  // testURL: \"http://localhost\",\n\n  // Setting this value to \"fake\" allows the use of fake timers for functions such as \"setTimeout\"\n  // timers: \"real\",\n\n  // A map from regular expressions to paths to transformers\n  transform: {\n    \"^.+\\\\.(js|jsx|ts|tsx)$\": [\"babel-jest\", { presets: [\"next/babel\"] }],\n  },\n\n  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation\n  // transformIgnorePatterns: [\n  //   \"/node_modules/\",\n  //   \"\\\\.pnp\\\\.[^\\\\/]+$\"\n  // ],\n\n  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them\n  // unmockedModulePathPatterns: undefined,\n\n  // Indicates whether each individual test should be reported during the run\n  // verbose: undefined,\n\n  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode\n  // watchPathIgnorePatterns: [],\n\n  // Whether to use watchman for file crawling\n  // watchman: true,\n};\n"
  },
  {
    "path": "next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/basic-features/typescript for more information.\n"
  },
  {
    "path": "next.config.js",
    "content": "const { withSentryConfig } = require(\"@sentry/nextjs\");\n\nconst nextConfig = {\n  reactStrictMode: true,\n};\n\nconst sentryWebpackPluginOptions = {\n  silent: true,\n};\n\nmodule.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"katla\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\",\n    \"test\": \"jest\",\n    \"update\": \"node ./.scripts/update.js\",\n    \"format\": \"prettier --write '**/*.{js,md,json,yml,yaml,css,ts,tsx}'\",\n    \"prepare\": \"husky install\"\n  },\n  \"lint-staged\": {\n    \"**/*\": \"prettier --write --ignore-unknown\"\n  },\n  \"dependencies\": {\n    \"@liveblocks/client\": \"^0.14.1\",\n    \"@liveblocks/node\": \"^0.3.0\",\n    \"@liveblocks/react\": \"^0.14.1\",\n    \"@reach/dialog\": \"^0.16.2\",\n    \"@reach/menu-button\": \"^0.16.2\",\n    \"@sentry/nextjs\": \"^6.17.4\",\n    \"@supabase/supabase-js\": \"^1.30.7\",\n    \"canvas-confetti\": \"^1.4.0\",\n    \"cheerio\": \"^1.0.0-rc.10\",\n    \"date-fns\": \"^2.28.0\",\n    \"file-saver\": \"^2.0.5\",\n    \"next\": \"^12.1.0\",\n    \"next-themes\": \"^0.0.16\",\n    \"react\": \"17.0.2\",\n    \"react-dom\": \"17.0.2\",\n    \"react-hot-toast\": \"^2.2.0\",\n    \"swr\": \"^1.1.2\"\n  },\n  \"devDependencies\": {\n    \"@octokit/rest\": \"^18.12.0\",\n    \"@testing-library/react-hooks\": \"^7.0.2\",\n    \"@types/react\": \"^17.0.38\",\n    \"autoprefixer\": \"^10.4.2\",\n    \"eslint\": \"8.7.0\",\n    \"eslint-config-next\": \"12.1.0\",\n    \"husky\": \"^7.0.4\",\n    \"jest\": \"^27.4.7\",\n    \"lint-staged\": \"^12.2.2\",\n    \"postcss\": \"^8.4.5\",\n    \"prettier\": \"^2.5.1\",\n    \"tailwindcss\": \"^3.0.15\",\n    \"typescript\": \"^4.5.4\"\n  }\n}\n"
  },
  {
    "path": "pages/_app.tsx",
    "content": "import Script from \"next/script\";\n\nimport \"../styles/globals.css\";\nimport { ThemeProvider } from \"next-themes\";\nimport Alert from \"../components/Alert\";\nimport EmojiRain from \"../components/EmojiRain\";\n\nexport default function MyApp({ Component, pageProps }) {\n  return (\n    <ThemeProvider storageKey=\"katla:theme\" attribute=\"class\">\n      <Alert />\n      <EmojiRain />\n      <Component {...pageProps} />\n      <Script id=\"track-ga\" strategy=\"afterInteractive\">{`\n        window.dataLayer = window.dataLayer || [];\n        function gtag(){dataLayer.push(arguments);}\n        gtag('js', new Date());\n        gtag('config', 'G-QNLF4HTK6S');\n      `}</Script>\n      <Script\n        src=\"https://www.googletagmanager.com/gtag/js?id=G-QNLF4HTK6S\"\n        strategy=\"afterInteractive\"\n      />\n    </ThemeProvider>\n  );\n}\n"
  },
  {
    "path": "pages/_document.tsx",
    "content": "import { Html, Head, Main, NextScript } from \"next/document\";\n\nexport default function Document() {\n  return (\n    <Html lang=\"id\" translate=\"no\">\n      <Head>\n        <meta name=\"google-adsense-account\" content=\"ca-pub-3081263972680635\" />\n        <script\n          async\n          src=\"https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3081263972680635\"\n          crossOrigin=\"anonymous\"\n        />\n      </Head>\n      <body>\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "pages/_error.js",
    "content": "import NextErrorComponent from \"next/error\";\n\nimport * as Sentry from \"@sentry/nextjs\";\n\nconst MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {\n  if (!hasGetInitialPropsRun && err) {\n    // getInitialProps is not called in case of\n    // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass\n    // err via _app.js so it can be captured\n    Sentry.captureException(err);\n    // Flushing is not required in this case as it only happens on the client\n  }\n\n  return <NextErrorComponent statusCode={statusCode} />;\n};\n\nMyError.getInitialProps = async (context) => {\n  const errorInitialProps = await NextErrorComponent.getInitialProps(context);\n\n  const { res, err, asPath } = context;\n\n  // Workaround for https://github.com/vercel/next.js/issues/8592, mark when\n  // getInitialProps has run\n  errorInitialProps.hasGetInitialPropsRun = true;\n\n  // Returning early because we don't want to log 404 errors to Sentry.\n  if (res?.statusCode === 404) {\n    return errorInitialProps;\n  }\n\n  // Running on the server, the response object (`res`) is available.\n  //\n  // Next.js will pass an err on the server if a page's data fetching methods\n  // threw or returned a Promise that rejected\n  //\n  // Running on the client (browser), Next.js will provide an err if:\n  //\n  //  - a page's `getInitialProps` threw or returned a Promise that rejected\n  //  - an exception was thrown somewhere in the React lifecycle (render,\n  //    componentDidMount, etc) that was caught by Next.js's React Error\n  //    Boundary. Read more about what types of exceptions are caught by Error\n  //    Boundaries: https://reactjs.org/docs/error-boundaries.html\n\n  if (err) {\n    Sentry.captureException(err);\n\n    // Flushing before returning is necessary if deploying to Vercel, see\n    // https://vercel.com/docs/platform/limits#streaming-responses\n    await Sentry.flush(2000);\n\n    return errorInitialProps;\n  }\n\n  // If this point is reached, getInitialProps was called without any\n  // information about what the error might be. This is unexpected and may\n  // indicate a bug introduced in Next.js, so record it in Sentry\n  Sentry.captureException(\n    new Error(`_error.js getInitialProps missing data at path: ${asPath}`)\n  );\n  await Sentry.flush(2000);\n\n  return errorInitialProps;\n};\n\nexport default MyError;\n"
  },
  {
    "path": "pages/api/define/[word].ts",
    "content": "import { withSentry } from \"@sentry/nextjs\";\nimport cheerio from \"cheerio\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\n\ninterface Definition {\n  def_text: string;\n}\n\ninterface KategloResponse {\n  kateglo: {\n    definition: ArrayLike<Definition>;\n  };\n}\n\nconst tokens = [\n  process.env.NEXT_PUBLIC_DEFINE_TOKEN,\n  process.env.THIRD_PARTY_DEFINE_TOKEN,\n];\n\nasync function handler(req: NextApiRequest, res: NextApiResponse) {\n  const word = req.query.word as string;\n  const auth = req.headers.authorization;\n\n  if (!auth || !auth.startsWith(\"token\")) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const [_, token] = auth.split(\" \");\n  if (!tokens.includes(token)) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  let definitions: String[] | null = null;\n  try {\n    try {\n      definitions = await fetchFromMakna(word);\n    } catch (err) {\n      console.log(`Failed to fetch from makna, using KBBI for word ${word}`, {\n        err,\n      });\n      definitions = await fetchFromKbbi(word);\n    }\n  } catch (err) {\n    console.warn(\n      `Failed to fetch definitions from KBBI, using kateglo.com for word ${word}`,\n      { err }\n    );\n    try {\n      const kateglo: KategloResponse = await fetch(\n        `https://kateglo.com/api.php?format=json&phrase=${word}`\n      ).then((res) => res.json());\n      definitions = Array.from(kateglo.kateglo.definition).map(\n        (d) => d.def_text\n      );\n    } catch (err) {\n      definitions = null;\n    }\n  }\n\n  if (definitions === null) {\n    res.status(500).json({ error: \"Failed to get definitions\" });\n    return;\n  }\n\n  res.setHeader(\n    \"Cache-Control\",\n    \"public, s-maxage=21600, stale-while-revalidate=86400\"\n  );\n  res.status(200).json(definitions);\n}\n\nexport default withSentry(handler);\n\nasync function fetchFromMakna(word: string): Promise<string[]> {\n  const json = await fetch(\n    `https://makna.fatihkalifa.workers.dev/${word}.json`\n  ).then((res) => res.json());\n  return json.flatMap((entry) => {\n    return entry.makna.map((makna) => makna.definisi);\n  });\n}\n\nasync function fetchFromKbbi(word: string): Promise<string[]> {\n  const html = await fetch(`https://kbbi.kemdikbud.go.id/entri/${word}`).then(\n    (res) => res.text()\n  );\n  const $ = cheerio.load(html);\n\n  const definitions = [];\n  $(\"ol li, ul.adjusted-par li\").each((i, el) => {\n    $(el).find(\"font\").remove();\n    definitions.push($(el).text());\n  });\n\n  return definitions;\n}\n"
  },
  {
    "path": "pages/api/live.ts",
    "content": "import { authorize } from \"@liveblocks/node\";\nimport { createClient } from \"@supabase/supabase-js\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\n\nconst supabase = createClient(\n  \"https://wwaoyidihlwhhlzykzup.supabase.co\",\n  process.env.SUPABASE_SECRET\n);\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  const room = req.body.room;\n  const auth = req.body.auth;\n\n  const { data, error } = await supabase\n    .from(\"rooms\")\n    .select()\n    .or(`auth.eq.${auth},invite.eq.${auth}`);\n\n  if (error || data.length === 0) {\n    return res\n      .status(403)\n      .json({ error: \"You don't have permission to access this room\" });\n  }\n\n  const result = await authorize({\n    room,\n    secret: process.env.LIVEBLOCKS_SECRET_KEY,\n    userId: req.body.username,\n  });\n\n  return res.status(result.status).json({\n    ...JSON.parse(result.body),\n    inviteKey: data[0].invite,\n  });\n}\n"
  },
  {
    "path": "pages/api/words.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse\n) {\n  const kbbi = await fetch(\"https://kbbi.vercel.app\").then((res) => res.json());\n  const words = kbbi.entries\n    .map((entry) => {\n      const [word] = entry.split(\"/\").reverse();\n      return word;\n    })\n    .filter((word) => /^[a-z]+$/.test(word) && word.length === 5)\n    .concat(october2021)\n    .sort();\n\n  res.setHeader(\n    \"Cache-Control\",\n    \"public, s-maxage=3600, stale-while-revalidate=86400\"\n  );\n  res.status(200).json(Array.from(new Set(words)));\n}\n\nconst october2021 = [\n  \"abate\",\n  \"abjat\",\n  \"ampet\",\n  \"arame\",\n  \"asrot\",\n  \"azeri\",\n  \"azuki\",\n  \"bakes\",\n  \"benin\",\n  \"beton\",\n  \"bezit\",\n  \"bksda\",\n  \"botia\",\n  \"burma\",\n  \"cekah\",\n  \"cekat\",\n  \"cenah\",\n  \"colon\",\n  \"dapuk\",\n  \"datau\",\n  \"datin\",\n  \"datum\",\n  \"denar\",\n  \"dobra\",\n  \"doula\",\n  \"duvet\",\n  \"filem\",\n  \"folio\",\n  \"gabut\",\n  \"gayor\",\n  \"gokil\",\n  \"gravi\",\n  \"hipmi\",\n  \"ilyas\",\n  \"impun\",\n  \"india\",\n  \"islan\",\n  \"kabul\",\n  \"kalke\",\n  \"kazak\",\n  \"korea\",\n  \"kudet\",\n  \"kurdi\",\n  \"kwaca\",\n  \"lakip\",\n  \"mandu\",\n  \"maori\",\n  \"nuget\",\n  \"ompok\",\n  \"palau\",\n  \"panda\",\n  \"pasto\",\n  \"porus\",\n  \"rasis\",\n  \"rouks\",\n  \"rupst\",\n  \"sango\",\n  \"sapun\",\n  \"silpa\",\n  \"sonde\",\n  \"struk\",\n  \"sudan\",\n  \"swazi\",\n  \"tafia\",\n  \"tajik\",\n  \"tando\",\n  \"tenge\",\n  \"titis\",\n  \"tonga\",\n  \"tuhur\",\n  \"uwete\",\n  \"uzbek\",\n  \"venda\",\n];\n"
  },
  {
    "path": "pages/arsip/[num].tsx",
    "content": "import { GetStaticPaths, GetStaticProps } from \"next\";\nimport { ComponentProps, useState } from \"react\";\n\nimport App from \"../../components/App\";\nimport Container from \"../../components/Container\";\nimport Header from \"../../components/Header\";\nimport HeadingWithNum from \"../../components/HeadingWithNum\";\nimport HelpModal from \"../../components/HelpModal\";\nimport { useModalState } from \"../../components/Modal\";\nimport SettingsModal from \"../../components/SettingsModal\";\nimport StatsModal from \"../../components/StatsModal\";\n\nimport { getAllAnswers } from \"../../utils/answers\";\nimport { encodeHashed } from \"../../utils/codec\";\nimport fetcher from \"../../utils/fetcher\";\nimport { isGameFinished, useGame } from \"../../utils/game\";\nimport { handleGameComplete, handleSubmitWord } from \"../../utils/message\";\nimport { GameStats } from \"../../utils/types\";\n\ninterface Props {\n  num: string;\n  hashed: string;\n  words: string[];\n}\n\nconst initialStats: GameStats = {\n  distribution: {\n    1: 0,\n    2: 0,\n    3: 0,\n    4: 0,\n    5: 0,\n    6: 0,\n    fail: 0,\n  },\n  currentStreak: 0,\n  maxStreak: 0,\n};\n\nexport default function Arsip(props: Props) {\n  const game = useGame(props.hashed, false);\n  const [stats, setStats] = useState(initialStats);\n  const [modalState, setModalState, resetModalState] = useModalState(\n    game,\n    stats\n  );\n\n  const headerProps: ComponentProps<typeof Header> = {\n    title: `Katla | Arsip #${props.num}`,\n    customHeading: <HeadingWithNum num={props.num} />,\n    path: `/arsip/${props.num}`,\n    ogImage: \"https://katla.vercel.app/og-arsip.png\",\n    themeColor: game.state.enableHighContrast ? \"#f5793a\" : \"#15803D\",\n    onShowHelp: () => setModalState(\"help\"),\n    onShowStats: isGameFinished(game)\n      ? () => setModalState(\"stats\")\n      : undefined,\n    onShowSettings: () => setModalState(\"settings\"),\n  };\n\n  if (!game.ready) {\n    return (\n      <Container>\n        <Header {...headerProps} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container>\n      <Header {...headerProps} />\n      <App\n        game={game}\n        stats={stats}\n        setStats={setStats}\n        showStats={() => setModalState(\"stats\")}\n        words={props.words}\n        onSubmit={handleSubmitWord}\n        onComplete={handleGameComplete}\n      />\n      <HelpModal isOpen={modalState === \"help\"} onClose={resetModalState} />\n      <StatsModal\n        game={game}\n        stats={stats}\n        isOpen={modalState === \"stats\"}\n        onClose={resetModalState}\n      />\n      <SettingsModal\n        isOpen={modalState === \"settings\"}\n        onClose={resetModalState}\n        game={game}\n      />\n    </Container>\n  );\n}\n\n// generate first and last 5 days\nexport const getStaticPaths: GetStaticPaths = async () => {\n  const answers = await getAllAnswers();\n\n  return {\n    paths: Array.from({ length: 5 }, (_, i) => ({\n      params: { num: `${i + 1}` },\n    })).concat(\n      Array.from({ length: 5 }, (_, i) => ({\n        params: { num: `${answers.length - i + 1}` },\n      }))\n    ),\n    fallback: \"blocking\",\n  };\n};\n\nexport const getStaticProps: GetStaticProps<Props> = async (ctx) => {\n  const num = ctx.params.num as string;\n  if (Number.isNaN(parseInt(num))) {\n    return {\n      notFound: true,\n    };\n  }\n\n  const numInt = Number(num);\n\n  const [answers, words] = await Promise.all([\n    getAllAnswers(),\n    fetcher(\"https://makna.fatihkalifa.workers.dev/words.json\"),\n  ]);\n\n  // archive should only return previous dates\n  if (numInt > answers.length) {\n    return {\n      notFound: true,\n      revalidate: 60,\n    };\n  }\n\n  return {\n    props: {\n      num,\n      hashed: encodeHashed(numInt, answers[numInt - 1], \"\"),\n      words,\n    },\n  };\n};\n"
  },
  {
    "path": "pages/arsip/index.tsx",
    "content": "import { useState } from \"react\";\n\nimport Container from \"../../components/Container\";\nimport Header from \"../../components/Header\";\nimport HelpModal from \"../../components/HelpModal\";\nimport Link from \"../../components/Link\";\nimport SettingsModal from \"../../components/SettingsModal\";\n\nimport { getAllAnswers } from \"../../utils/answers\";\nimport { initialState, useGamePersistedState } from \"../../utils/game\";\nimport { Game } from \"../../utils/types\";\n\nexport default function Arsip({ nums }) {\n  const [modalState, setModalState] = useState(null);\n  const [gameState, setGameState] = useGamePersistedState(initialState);\n  const game: Game = {\n    hash: \"\",\n    num: -1,\n    migrate: () => {},\n    state: gameState,\n    setState: setGameState,\n    ready: true,\n    readyState: \"ready\" as const,\n    trackInvalidWord: () => {},\n  };\n\n  return (\n    <Container>\n      <Header\n        path=\"/arsip\"\n        title=\"Katla | Arsip\"\n        keywords={[\n          \"arsip\",\n          \"archive\",\n          \"game\",\n          \"permainan\",\n          \"tebak\",\n          \"kata\",\n          \"rahasia\",\n          \"wordle\",\n          \"indonesia\",\n          \"kbbi\",\n        ]}\n        ogImage=\"https://katla.vercel.app/og-arsip.png\"\n        onShowHelp={() => setModalState(\"help\")}\n        onShowSettings={() => setModalState(\"settings\")}\n      />\n      <div className=\"px-4 mx-auto max-w-lg w-full pt-2 pb-4 text-left\">\n        <h2 className=\"text-2xl font-semibold mb-4\">Arsip</h2>\n        <p className=\"mb-2\">\n          Berikut adalah daftar kata telah digunakan sebelumnya. Kamu bisa\n          menggunakan <em>link</em> di bawah, atau langsung memasukkan alamat\n          pada <em>address bar</em> sesuai angka hari, misal:{\" \"}\n          <a href=\"https://katla.vercel.app/arsip/1\" className=\"color-accent\">\n            https://katla.vercel.app/arsip/1\n          </a>\n        </p>\n        <p className=\"mb-2\">\n          Arsip hanya mencakup daftar di masa lalu dan tidak dapat digunakan\n          untuk melihat masa depan 😌\n        </p>\n        <ol className=\"mx-8 list-disc\">\n          {Array(nums)\n            .fill(\"\")\n            .map((_, i) => (\n              <li key={i}>\n                <Link href={`/arsip/${i + 1}`}>{`Hari ke-${i + 1}`}</Link>\n              </li>\n            ))}\n        </ol>\n      </div>\n      <HelpModal\n        isOpen={modalState === \"help\"}\n        onClose={() => setModalState(null)}\n      />\n      <SettingsModal\n        game={game}\n        isOpen={modalState === \"settings\"}\n        onClose={() => setModalState(null)}\n      />\n    </Container>\n  );\n}\n\nexport const getStaticProps = async () => {\n  const answers = await getAllAnswers();\n  return {\n    props: {\n      nums: answers.length - 1,\n    },\n    revalidate: 3600,\n  };\n};\n"
  },
  {
    "path": "pages/bantuan.tsx",
    "content": "import { FormEvent, useEffect, useState } from \"react\";\nimport LocalStorage from \"../utils/browser\";\nimport {\n  GAME_STATE_KEY,\n  GAME_STATS_KEY,\n  INVALID_WORDS_KEY,\n  LAST_HASH_KEY,\n  LAST_SESSION_RESET_KEY,\n} from \"../utils/constants\";\n\nexport default function Debug(props: { hashed: string }) {\n  const [debugCode, setDebugCode] = useState(\"\");\n  useEffect(() => {\n    const gameState = LocalStorage.getItem(GAME_STATE_KEY);\n    const gameStats = LocalStorage.getItem(GAME_STATS_KEY);\n    const lastHash = LocalStorage.getItem(LAST_HASH_KEY);\n    const invalidWords = LocalStorage.getItem(INVALID_WORDS_KEY);\n    const lastSessionReset = LocalStorage.getItem(LAST_SESSION_RESET_KEY);\n    const now = new Date();\n    let timezone = \"Unknown\";\n    try {\n      timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n    } catch (err) {}\n\n    setDebugCode(\n      btoa(\n        [\n          props.hashed,\n          lastHash,\n          gameState,\n          gameStats,\n          invalidWords,\n          lastSessionReset,\n          now.getTime(),\n          now.getTimezoneOffset(),\n          timezone,\n          navigator.userAgent,\n          window.location.host,\n        ].join(\":\")\n      )\n    );\n    // eslint-disable-next-line\n  }, []);\n\n  const messagePrefix = `Halo, saya ingin melaporkan masalah tentang ...`;\n  const mailToLink = `mailto:help@katla.id?subject=Problem katla&body=${messagePrefix}%0D%0A%0D%0AKode: ${debugCode}`;\n\n  const confirmImport = (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    // @ts-ignore\n    const newDebugCode = e.target.debugCode.value;\n    let confirmed: boolean;\n    if (newDebugCode === debugCode) {\n      confirmed = window.confirm(\n        \"Kode yang anda masukkan sama dengan kode di perangkat ini. Apakah anda yakin ingin mengimpor?\"\n      );\n    } else {\n      confirmed = window.confirm(\n        \"Statistik yang ada di perangkat ini akan diganti dari statistik dari kode. Apakah anda yakin?\"\n      );\n    }\n\n    if (!confirmed) return;\n\n    try {\n      const decoded = atob(newDebugCode);\n      const st0 = decoded.indexOf('{\"distribution');\n      const ste = decoded.indexOf(\"}:\");\n      const stats = decoded.slice(st0, ste + 1);\n      LocalStorage.setItem(GAME_STATS_KEY, stats);\n      alert(\"Statistik berhasil diimpor\");\n      window.location.replace(\"/\");\n    } catch (err) {\n      console.error(err);\n      alert(\"Kode yang anda masukkan tidak valid\");\n    }\n  };\n\n  return (\n    <div className=\"dark:text-white max-w-lg mx-auto mt-4 px-3\">\n      <NewSiteWarning />\n      <h1 className=\"text-3xl mb-4\">Bantuan</h1>\n      {debugCode === \"\" ? (\n        <span>Generating debug code...</span>\n      ) : (\n        <>\n          <p className=\"mb-4\">\n            Klik{\" \"}\n            <a className=\"underline text-blue-400\" href={mailToLink}>\n              tautan berikut\n            </a>{\" \"}\n            untuk mengirim email.\n          </p>\n          <p className=\"mb-4\">\n            Klik {/* eslint-disable-next-line */}\n            <a href=\"/\" className=\"underline text-blue-400\">\n              tautan berikut\n            </a>{\" \"}\n            untuk kembali ke beranda\n          </p>\n          <strong>Kode bantuan</strong>\n          <pre className=\"border border-gray-300 p-3 whitespace-pre-wrap break-all \">\n            {debugCode}\n          </pre>\n        </>\n      )}\n      <h2 className=\"text-2xl mt-4 mb-4\">Impor Statistik</h2>\n      <p className=\"mb-4\">\n        Masukkan debug code yang anda dapat dari halaman ini di perangkat lain\n        untuk mengimpor statistik dari perangkat tersebut\n      </p>\n      <form onSubmit={confirmImport}>\n        <textarea\n          name=\"debugCode\"\n          className=\"w-full h-64 border border-gray-300 p-2 rounded-r overflow-hidden\"\n          placeholder=\"Salin kode di sini\"\n        />\n        <button\n          type=\"submit\"\n          className=\"border-none px-3 py-1 bg-accent text-white rounded-sm overflow-hidden mb-4\"\n        >\n          Impor\n        </button>\n      </form>\n    </div>\n  );\n}\n\nfunction NewSiteWarning() {\n  return (\n    <div className=\"mx-auto text-sm mb-4 p-3 bg-yellow-100 text-black rounded-sm overflow-hidden\">\n      <p className=\"mb-2\">\n        Mulai 4 Oktober 2023, Katla akan menggunakan domain baru di{\" \"}\n        <a href=\"https://katla.id\" className=\"underline\">\n          katla.id\n        </a>\n        . Statistik permainan anda akan dipindahkan secara otomatis.\n      </p>\n    </div>\n  );\n}\n\nexport { getStaticProps } from \"./index\";\n"
  },
  {
    "path": "pages/index.tsx",
    "content": "import * as Sentry from \"@sentry/nextjs\";\nimport fs from \"fs/promises\";\nimport { GetStaticProps } from \"next\";\nimport { useRouter } from \"next/router\";\nimport path from \"path\";\nimport { ComponentProps, useEffect } from \"react\";\n\nimport App from \"../components/App\";\nimport Container from \"../components/Container\";\nimport Header from \"../components/Header\";\nimport HeadingWithNum from \"../components/HeadingWithNum\";\nimport HelpModal from \"../components/HelpModal\";\nimport { useModalState } from \"../components/Modal\";\nimport SettingsModal from \"../components/SettingsModal\";\nimport SponsorshipFooter from \"../components/SponsorshipFooter\";\nimport StatsModal from \"../components/StatsModal\";\n\nimport LocalStorage from \"../utils/browser\";\nimport { encodeHashed } from \"../utils/codec\";\nimport { GAME_STATS_KEY, LAST_HASH_KEY } from \"../utils/constants\";\nimport fetcher from \"../utils/fetcher\";\nimport { getTotalPlay, useGame, useRemainingTime } from \"../utils/game\";\nimport { handleGameComplete, handleSubmitWord } from \"../utils/message\";\nimport { trackEvent } from \"../utils/tracking\";\nimport { GameStats, MigrationData } from \"../utils/types\";\nimport createStoredState from \"../utils/useStoredState\";\n\ninterface Props {\n  hashed: string;\n  words: string[];\n}\n\nconst initialStats: GameStats = {\n  distribution: {\n    1: 0,\n    2: 0,\n    3: 0,\n    4: 0,\n    5: 0,\n    6: 0,\n    fail: 0,\n  },\n  currentStreak: 0,\n  maxStreak: 0,\n};\n\nconst useStats = createStoredState<GameStats>(GAME_STATS_KEY);\n\nconst VALID_STATS_DELAY_MS = 5000;\n\nexport default function Home(props: Props) {\n  const remainingTime = useRemainingTime();\n  const game = useGame(props.hashed);\n  const [stats, setStats] = useStats(initialStats);\n  const [modalState, setModalState, resetModalState] = useModalState(\n    game,\n    stats\n  );\n\n  const router = useRouter();\n\n  useEffect(() => {\n    const migrationData = router.query.migrate;\n    if (!migrationData) {\n      return;\n    }\n\n    let data: MigrationData;\n    try {\n      data = JSON.parse(decodeURIComponent(migrationData as string));\n    } catch (err) {\n      Sentry.captureException(err, { extra: { migrationData } });\n      return;\n    }\n\n    const timeDiff = Date.now() - data.time;\n    if (timeDiff > VALID_STATS_DELAY_MS) {\n      trackEvent(\"invalidMigrationTime\", { timeDiff });\n      router.replace(\"/\");\n      return;\n    }\n\n    const hasExistingData = checkExistingData(data.stats);\n\n    let shouldContinue = true;\n    if (hasExistingData) {\n      shouldContinue = window.confirm(\n        `Kamu sudah memiliki statistik yang tersimpan di katla.id, apakah kamu ingin menggantinya dengan statistik terakhir dari katla.vercel.app?`\n      );\n    }\n\n    if (!shouldContinue) {\n      trackEvent(\"migrationCancelled\", {\n        hasExistingData: hasExistingData.toString(),\n      });\n      router.replace(\"/\");\n      return;\n    }\n\n    LocalStorage.setItem(GAME_STATS_KEY, JSON.stringify(data.stats));\n    LocalStorage.setItem(LAST_HASH_KEY, data.lastHash);\n    setStats(data.stats);\n    trackEvent(\"migrationSuccess\", {});\n    router.replace(\"/\");\n  }, [router]);\n\n  const headerProps: ComponentProps<typeof Header> = {\n    customHeading: (\n      <HeadingWithNum\n        num={game.ready ? game.num : null}\n        enableLiarMode={game.state.enableLiarMode}\n      />\n    ),\n    themeColor: game.state.enableHighContrast ? \"#f5793a\" : \"#15803D\",\n    onShowHelp: () => setModalState(\"help\"),\n    onShowStats: () => setModalState(\"stats\"),\n    onShowSettings: () => setModalState(\"settings\"),\n    showLiarOption: game.ready && game.num === 71 && !game.state.enableLiarMode,\n  };\n\n  if (game.readyState === \"init\") {\n    return (\n      <Container>\n        <Header {...headerProps} />\n      </Container>\n    );\n  }\n\n  return (\n    <Container>\n      <Header\n        {...headerProps}\n        warnStorageDisabled={game.readyState === \"no-storage\"}\n      />\n      <App\n        game={game}\n        stats={stats}\n        setStats={setStats}\n        showStats={() => setModalState(\"stats\")}\n        words={props.words}\n        onSubmit={handleSubmitWord}\n        onComplete={handleGameComplete}\n      />\n      <HelpModal isOpen={modalState === \"help\"} onClose={resetModalState} />\n      <StatsModal\n        isOpen={modalState === \"stats\"}\n        onClose={resetModalState}\n        game={game}\n        stats={stats}\n        remainingTime={remainingTime}\n      />\n      <SettingsModal\n        isOpen={modalState === \"settings\"}\n        onClose={resetModalState}\n        game={game}\n      />\n      <SponsorshipFooter />\n    </Container>\n  );\n}\n\nexport const getStaticProps: GetStaticProps<Props> = async () => {\n  const [answers, words] = await Promise.all([\n    fs\n      .readFile(path.join(process.cwd(), \"./.scripts/answers.csv\"), \"utf8\")\n      .then((text) =>\n        text\n          .split(\",\")\n          .map((s) => s.trim())\n          .filter(Boolean)\n      ),\n    fetcher(\"https://makna.fatihkalifa.workers.dev/words.json\"),\n  ]);\n\n  return {\n    props: {\n      hashed: encodeHashed(\n        answers.length,\n        answers[answers.length - 1],\n        answers[answers.length - 2]\n      ),\n      words: words,\n    },\n    revalidate: 60,\n  };\n};\n\nconst checkExistingData = (newStats: GameStats) => {\n  if (!LocalStorage.getItem(GAME_STATS_KEY)) {\n    return false;\n  }\n\n  try {\n    const currentStats: GameStats = JSON.parse(\n      LocalStorage.getItem(GAME_STATS_KEY) as string\n    );\n    const totalPlay = getTotalPlay(currentStats);\n    const newTotalPlay = getTotalPlay(newStats);\n    if (totalPlay !== newTotalPlay) {\n      return true;\n    }\n\n    if (currentStats.maxStreak !== newStats.maxStreak) {\n      return true;\n    }\n\n    if (currentStats.currentStreak !== newStats.currentStreak) {\n      return true;\n    }\n\n    for (const v in currentStats.distribution) {\n      if (currentStats.distribution[v] !== newStats.distribution[v]) {\n        return true;\n      }\n    }\n\n    return false;\n  } catch (err) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "pages/lawan.tsx",
    "content": "import { createClient } from \"@liveblocks/client\";\nimport {\n  LiveblocksProvider,\n  RoomProvider,\n  useBroadcastEvent,\n  useOthers,\n  useSelf,\n} from \"@liveblocks/react\";\nimport { GetStaticProps } from \"next\";\nimport {\n  ComponentProps,\n  FormEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport App from \"../components/App\";\nimport Container from \"../components/Container\";\nimport Header from \"../components/Header\";\nimport Modal, { useModalState } from \"../components/Modal\";\n\nimport { useTheme } from \"next-themes\";\nimport HelpModal from \"../components/HelpModal\";\nimport LiveStatsModal from \"../components/LiveStatsModal\";\nimport SettingsModal from \"../components/SettingsModal\";\nimport { decode, encode } from \"../utils/codec\";\nimport fetcher from \"../utils/fetcher\";\nimport {\n  defaultScore,\n  generateRoomId,\n  getEmojiFromScore,\n  getTotalScore,\n  LiveGame,\n  shareInviteLink,\n  useLiveGame,\n} from \"../utils/liveGame\";\nimport { GameStats, LiveConfig } from \"../utils/types\";\n\ninterface Props {\n  words: string[];\n}\n\nexport default function Lawan({ words }: Props) {\n  const [username, setUsername] = useState(\"\");\n  const [roomId, setRoomId] = useState(\"\");\n  const [isHost, setIsHost] = useState(false);\n  const [inviteKey, setInviteKey] = useState(null);\n  const client = useRef<ReturnType<typeof createClient>>(null);\n\n  useEffect(() => {\n    const query = new URLSearchParams(window.location.search);\n    const room = query.get(\"room\");\n    const auth = query.get(\"auth\");\n    const invite = query.get(\"invite\");\n\n    if (!room && auth) {\n      query.set(\"room\", generateRoomId(encode(auth)));\n      window.location.search = query.toString();\n      return;\n    }\n\n    if (room) {\n      setRoomId(room);\n\n      const [_, eauth, _id] = room.split(\"-\");\n      if (auth) {\n        if (decode(eauth) !== auth) {\n          window.location.replace(\"/404\");\n          return;\n        }\n\n        setIsHost(true);\n        return;\n      }\n\n      if (!invite) {\n        window.location.replace(\"/404\");\n        return;\n      }\n    }\n  }, []);\n\n  if (!roomId) {\n    return <div>{\"loading...\"}</div>;\n  }\n\n  function handleSubmit(e: FormEvent) {\n    const username = (e.target as any).name.value;\n    e.preventDefault();\n    setUsername(username);\n    client.current = createClient({\n      authEndpoint: async (room) => {\n        const query = new URLSearchParams(window.location.search);\n        const response = await fetch(\"/api/live\", {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            room,\n            auth: query.get(\"auth\") ?? query.get(\"invite\"),\n            username,\n          }),\n        });\n\n        // TODO: validate\n        const { inviteKey, ...liveblocks } = await response.json();\n        setInviteKey(inviteKey);\n        return liveblocks;\n      },\n    });\n  }\n\n  if (!username) {\n    return (\n      <Modal isOpen>\n        <form onSubmit={handleSubmit}>\n          <label htmlFor=\"username\" className=\"mb-4 block\">\n            Masukkan username\n          </label>\n          <input\n            id=\"username\"\n            className=\"text-gray-800 dark:text-gray-100 p-2 mr-4 rounded-sm bg-gray-200 dark:bg-gray-800\"\n            name=\"name\"\n            autoComplete=\"off\"\n          />\n          <button className=\"px-4 py-2 rounded-sm bg-green-600\">Pilih</button>\n        </form>\n      </Modal>\n    );\n  }\n\n  return (\n    <LiveblocksProvider client={client.current}>\n      <RoomProvider id={roomId as string}>\n        <Main\n          words={words}\n          config={{\n            isHost,\n            inviteKey,\n            roomId,\n          }}\n        />\n      </RoomProvider>\n    </LiveblocksProvider>\n  );\n}\n\nexport const getStaticProps: GetStaticProps<Props> = async () => {\n  const words = await fetcher(\n    \"https://makna.fatihkalifa.workers.dev/words.json\"\n  );\n  return {\n    props: {\n      words,\n    },\n  };\n};\n\nconst initialStats: GameStats = {\n  distribution: {\n    1: 0,\n    2: 0,\n    3: 0,\n    4: 0,\n    5: 0,\n    6: 0,\n    fail: 0,\n  },\n  currentStreak: 0,\n  maxStreak: 0,\n};\n\ninterface MainProps {\n  words: string[];\n  config: LiveConfig;\n}\n\nfunction Main({ words, config }: MainProps) {\n  const game = useLiveGame(words);\n  const [stats, setStats] = useState(initialStats);\n  const broadcast = useBroadcastEvent();\n  const others = useOthers();\n  const self = useSelf();\n  const [modalState, setModalState, resetModalState] = useModalState(\n    game,\n    stats\n  );\n\n  const handleSendEmoji = useCallback(\n    (emoji: string) => {\n      broadcast({ type: \"emoji\", emoji, username: self.id });\n    },\n    [broadcast, self?.id]\n  );\n\n  const headerProps: ComponentProps<typeof Header> = {\n    path: \"/lawan\",\n    onSendEmoji: others.count > 0 ? handleSendEmoji : undefined,\n    customHeading: (\n      <div className=\"dark:text-gray-300 text-gray-700 flex relative\">\n        <div className=\"uppercase text-center flex flex-col items-center\">\n          <span className=\"block\">Lawan</span>\n          <span className=\"block text-xs\" style={{ letterSpacing: 0 }}>\n            Katla\n          </span>\n        </div>\n        {game.ready && game.num > 0 && (\n          <sup className=\"top-1 tracking-tight\" style={{ fontSize: \"45%\" }}>\n            #{game.num}\n          </sup>\n        )}\n      </div>\n    ),\n    onShowHelp: () => setModalState(\"help\"),\n    onShowStats: () => setModalState(\"stats\"),\n    onShowSettings: () => setModalState(\"settings\"),\n    isLiveMode: true,\n  };\n\n  const playerCount = others.toArray().length + 1;\n  const isReady = playerCount > 1;\n\n  useEffect(() => {\n    if (isReady && game.num === 0 && config.isHost) {\n      game.start();\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isReady, game.num]);\n\n  return (\n    <Container>\n      <Header {...headerProps} />\n      <LiveGameBar game={game} config={config} isReady={isReady} />\n      {game.hash && isReady && (\n        <App\n          game={game}\n          stats={stats}\n          setStats={setStats}\n          showStats={() => void 0}\n          words={words}\n        />\n      )}\n      <HelpModal\n        isOpen={modalState === \"help\"}\n        onClose={resetModalState}\n        isLiveMode\n      />\n      <LiveStatsModal\n        isOpen={modalState === \"stats\"}\n        onClose={resetModalState}\n        totalPlay={game.num ?? 0}\n      />\n      <SettingsModal\n        isOpen={modalState === \"settings\"}\n        onClose={resetModalState}\n        game={game}\n        liveConfig={config}\n      />\n    </Container>\n  );\n}\n\ninterface GameBarProps {\n  game: LiveGame;\n  config: LiveConfig;\n  isReady: boolean;\n}\n\nfunction LiveGameBar(props: GameBarProps) {\n  const { game, config, isReady } = props;\n  const { resolvedTheme } = useTheme();\n  const others = useOthers();\n  const currentUser = useSelf();\n\n  const users = others.toArray().concat(currentUser).filter(Boolean);\n\n  const userScores = users\n    .map((user) => ({\n      id: user.id,\n      scores: user.presence?.scores ?? defaultScore,\n      isFailed: user.presence?.isFailed ?? false,\n    }))\n    .sort((a, b) => {\n      return getTotalScore(a.scores) > getTotalScore(b.scores) ? -1 : 1;\n    });\n\n  function handleShare() {\n    shareInviteLink(config);\n  }\n\n  return (\n    <div className=\"relative z-auto pb-4\" id=\"game-bar\">\n      {isReady ? (\n        <div className=\"flex flex-row overflow-x-auto\">\n          {userScores.map((entry) => (\n            <div\n              key={entry.id}\n              className=\"flex-grow-0 flex-shrink-0 user-score self-center px-2\"\n              style={{ maxWidth: 150, opacity: entry.isFailed ? 0.4 : 1 }}\n            >\n              <span className=\"text-ellipsis block overflow-clip\">\n                {entry.id}\n              </span>\n              <div className=\"tracking-widest\">\n                {entry.scores.map((score) =>\n                  getEmojiFromScore(\n                    score,\n                    resolvedTheme === \"dark\",\n                    game.state.enableHighContrast\n                  )\n                )}\n              </div>\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div className=\"text-center\">\n          <div>Menunggu pemain lain terhubung...</div>\n          {config.inviteKey && (\n            <button\n              className=\"block text-center color-accent w-full\"\n              onClick={handleShare}\n            >\n              Ajak pemain\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "public/ads.txt",
    "content": "google.com, pub-3081263972680635, DIRECT, f08c47fec0942fa0"
  },
  {
    "path": "public/google563f40b6a67a6d75.html",
    "content": "google-site-verification: google563f40b6a67a6d75.html\n"
  },
  {
    "path": "sentry.client.config.js",
    "content": "// This file configures the initialization of Sentry on the browser.\n// The config you add here will be used whenever a page is visited.\n// https://docs.sentry.io/platforms/javascript/guides/nextjs/\n\nimport * as Sentry from \"@sentry/nextjs\";\n\nconst SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;\n\nSentry.init({\n  dsn: SENTRY_DSN,\n  // Adjust this value in production, or use tracesSampler for greater control\n  tracesSampleRate: 0.2,\n  // ...\n  // Note: if you want to override the automatic release value, do not set a\n  // `release` value here - use the environment variable `SENTRY_RELEASE`, so\n  // that it will also get attached to your source maps\n});\n"
  },
  {
    "path": "sentry.properties",
    "content": "defaults.url=https://sentry.io/\ndefaults.org=pveyes\ndefaults.project=katla\ncli.executable=../../.npm/_npx/26968/lib/node_modules/@sentry/wizard/node_modules/@sentry/cli/bin/sentry-cli\n"
  },
  {
    "path": "sentry.server.config.js",
    "content": "import * as Sentry from \"@sentry/nextjs\";\n\nconst SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;\n\nSentry.init({\n  dsn: SENTRY_DSN,\n  // Adjust this value in production, or use tracesSampler for greater control\n  tracesSampleRate: 0.1,\n  // ...\n  // Note: if you want to override the automatic release value, do not set a\n  // `release` value here - use the environment variable `SENTRY_RELEASE`, so\n  // that it will also get attached to your source maps\n});\n"
  },
  {
    "path": "styles/globals.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  @apply dark:bg-gray-900 bg-white min-h-screen max-h-screen;\n  min-height: -webkit-fill-available;\n  max-height: -webkit-fill-available;\n}\n\n.absolute-center {\n  position: absolute;\n  top: 15%;\n  left: 50%;\n  transform: translateX(-50%) translateY(-15%);\n  z-index: 10;\n}\n\n.user-score:first-child {\n  margin-left: auto;\n}\n\n.user-score:last-child {\n  margin-right: auto;\n}\n\n:root {\n  --k-bg-accent: #15803d;\n  --k-bg-accent-hover: #16a34a;\n  --k-bg-correct: #15803d;\n  --k-bg-exist: #ca8a04;\n}\n\n:root[data-katla-hc=\"true\"] {\n  --k-bg-accent: #f5793a;\n  --k-bg-accent-hover: #f6a248;\n  --k-bg-correct: #f5793a;\n  --k-bg-exist: #85c0f9;\n}\n\n.bg-correct {\n  background-color: var(--k-bg-correct);\n}\n\n.bg-exist {\n  background-color: var(--k-bg-exist);\n}\n\n.bg-accent {\n  background-color: var(--k-bg-accent);\n}\n\n.bg-ig {\n  background: #f09433;\n  background: linear-gradient(\n    45deg,\n    #f09433 0%,\n    #e6683c 25%,\n    #dc2743 50%,\n    #cc2366 75%,\n    #bc1888 100%\n  );\n}\n\n.color-accent {\n  color: var(--k-bg-accent);\n}\n\n.color-accent:hover {\n  color: var(--k-bg-accent-hover);\n}\n\n.text-dynamic {\n  font-size: clamp(0.5rem, min(3vh, 3vw), 1.5rem);\n}\n\n@media (min-height: 550px) {\n  .text-dynamic {\n    font-size: clamp(1rem, min(5vh, 5vw), 2.25rem);\n  }\n}\n\n@keyframes bounce {\n  0%,\n  20% {\n    transform: translateY(0);\n  }\n\n  40% {\n    transform: translateY(-30px);\n  }\n\n  50% {\n    transform: translateY(5px);\n  }\n\n  60% {\n    transform: translateY(-15px);\n  }\n\n  80% {\n    transform: translateY(2px);\n  }\n\n  100% {\n    transform: translateY(0);\n  }\n}\n\n@keyframes shake {\n  10%,\n  90% {\n    transform: translateX(-1px);\n  }\n\n  20%,\n  80% {\n    transform: translateX(2px);\n  }\n\n  30%,\n  50%,\n  70% {\n    transform: translateX(-4px);\n  }\n\n  40%,\n  60% {\n    transform: translateX(4px);\n  }\n}\n\n@keyframes flip {\n  0% {\n    transform: rotateX(0);\n  }\n  50% {\n    transform: rotateX(-90deg);\n  }\n  100% {\n    transform: rotateX(0);\n  }\n}\n\n@keyframes slide-down {\n  0% {\n    opacity: 0;\n    transform: translateY(-10px);\n  }\n  100% {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.slide-down[data-reach-menu-list],\n.slide-down[data-reach-menu-items] {\n  left: -16px;\n  animation: slide-down 0.2s ease;\n}\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "module.exports = {\n  content: [\n    \"./pages/**/*.{js,ts,jsx,tsx}\",\n    \"./components/**/*.{js,ts,jsx,tsx}\",\n  ],\n  darkMode: \"class\",\n  theme: {\n    extend: {\n      gridTemplateColumns: {\n        leaderboard: \"90px 1fr\",\n      },\n    },\n    borderWidth: {\n      DEFAULT: \"1px\",\n      0: \"0px\",\n      2: \"2px\",\n      3: \"3px\",\n    },\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": false,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"incremental\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\"\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "utils/__tests__/answer.test.js",
    "content": "import { getAnswerStates } from \"../game\";\n\ntest(\"should mark `c` characters\", () => {\n  expect(getAnswerStates(\"SEMUA\", \"BENUA\")).toEqual([\"w\", \"c\", \"w\", \"c\", \"c\"]);\n});\n\ntest(\"should prioritize `c` before `e` and `w` characters\", () => {\n  expect(getAnswerStates(\"BABAT\", \"BENUA\")).toEqual([\"c\", \"e\", \"w\", \"w\", \"w\"]);\n\n  expect(getAnswerStates(\"GAGAP\", \"GANAR\")).toEqual([\"c\", \"c\", \"w\", \"c\", \"w\"]);\n\n  expect(getAnswerStates(\"NANAR\", \"GANAR\")).toEqual([\"w\", \"c\", \"c\", \"c\", \"c\"]);\n});\n"
  },
  {
    "path": "utils/__tests__/game.test.js",
    "content": "/**\n * @jest-environment jsdom\n */\njest.useFakeTimers();\n\nimport { renderHook } from \"@testing-library/react-hooks\";\n\nimport { useGame } from \"../game\";\nimport { GAME_STATE_KEY, INVALID_WORDS_KEY, LAST_HASH_KEY } from \"../constants\";\nimport { decode, encode, encodeHashed } from \"../codec\";\nimport LocalStorage from \"../browser\";\n\nclass LocalStorageMock {\n  constructor() {\n    this.store = {};\n  }\n\n  clear() {\n    this.store = {};\n  }\n\n  getItem(key) {\n    return this.store[key] || null;\n  }\n\n  setItem(key, value) {\n    this.store[key] = String(value);\n  }\n\n  removeItem(key) {\n    delete this.store[key];\n  }\n}\n\nbeforeAll(() => {\n  global.localStorage = new LocalStorageMock();\n});\n\nafterEach(() => {\n  localStorage.clear();\n});\n\nconst num = 20;\nconst hashed = encodeHashed(num, \"latest\", \"previous\");\n\ntest(\"first time playing, ready for new game\", () => {\n  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(decode(result.current.hash)).toBe(\"latest\");\n  expect(result.current.num).toBe(num);\n  expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);\n});\n\ntest(\"first time, not ready for new game\", () => {\n  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(decode(result.current.hash)).toBe(\"previous\");\n  expect(result.current.num).toBe(num - 1);\n  expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);\n});\n\ntest(\"already played, ready for new game\", () => {\n  const answers = [\"ganar\", \"pakar\", \"syair\"];\n  const attempt = 3;\n  const lastCompletedDate = Date.now();\n  const enableHardMode = true;\n  const enableHighContrast = true;\n\n  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());\n  localStorage.setItem(LAST_HASH_KEY, encode(\"previous\"));\n  localStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([\"fucek\"]));\n  localStorage.setItem(\n    GAME_STATE_KEY,\n    JSON.stringify({\n      answers,\n      attempt,\n      lastCompletedDate,\n      enableHardMode,\n      enableHighContrast,\n    })\n  );\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(decode(result.current.hash)).toBe(\"latest\");\n  expect(result.current.num).toBe(20);\n  expect(result.current.state.answers).toEqual(Array(6).fill(\"\"));\n  expect(result.current.state.attempt).toBe(0);\n\n  // keep other state\n  expect(result.current.state.lastCompletedDate).toBe(lastCompletedDate);\n  expect(result.current.state.enableHardMode).toBe(enableHardMode);\n  expect(result.current.state.enableHighContrast).toBe(enableHighContrast);\n\n  // reset invalid word list\n  expect(localStorage.getItem(INVALID_WORDS_KEY)).toBe(\"[]\");\n});\n\ntest(\"already played, not ready for new game\", () => {\n  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());\n  localStorage.setItem(LAST_HASH_KEY, encode(\"previous\"));\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(decode(result.current.hash)).toBe(\"previous\");\n  expect(result.current.num).toBe(num - 1);\n});\n\ntest(\"already played, but new hash already generated\", () => {\n  const answers = [\"ganar\", \"pakar\", \"syair\"];\n  const attempt = 3;\n  const lastCompletedDate = Date.now();\n  const enableHardMode = true;\n  const enableHighContrast = true;\n\n  jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());\n  localStorage.setItem(\n    GAME_STATE_KEY,\n    JSON.stringify({\n      answers,\n      attempt,\n      lastCompletedDate,\n      enableHardMode,\n      enableHighContrast,\n    })\n  );\n\n  // played on date X, now on date Y\n  // but hashed already on date Y and Z\n  localStorage.setItem(LAST_HASH_KEY, encode(\"before\"));\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(decode(result.current.hash)).toBe(\"previous\");\n  expect(result.current.num).toBe(19);\n  expect(result.current.state.answers).toEqual(Array(6).fill(\"\"));\n\n  expect(localStorage.getItem(LAST_HASH_KEY)).toBe(encode(\"previous\"));\n});\n\ntest(\"currently playing, should not reset state\", async () => {\n  const answers = [\"ganar\", \"pakar\", \"syair\"];\n  const attempt = 3;\n\n  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());\n  localStorage.setItem(LAST_HASH_KEY, encode(\"latest\"));\n  localStorage.setItem(\n    GAME_STATE_KEY,\n    JSON.stringify({\n      answers,\n      attempt,\n    })\n  );\n\n  const { result } = renderHook(() => useGame(hashed));\n  expect(result.current.ready).toBe(true);\n  expect(decode(result.current.hash)).toBe(\"latest\");\n  expect(result.current.num).toBe(20);\n  expect(result.current.state.answers).toEqual(answers);\n  expect(result.current.state.attempt).toBe(attempt);\n});\n\ntest(\"already played, refresh event\", async () => {\n  const answers = [\"ganar\", \"pakar\", \"syair\"];\n  const attempt = 3;\n\n  jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());\n  localStorage.setItem(LAST_HASH_KEY, encode(\"latest\"));\n  localStorage.setItem(\n    GAME_STATE_KEY,\n    JSON.stringify({\n      answers,\n      attempt,\n    })\n  );\n\n  let currentHashed = hashed;\n  const { result, rerender } = renderHook(() => useGame(currentHashed));\n  expect(decode(result.current.hash)).toBe(\"latest\");\n  expect(result.current.num).toBe(20);\n\n  // refresh\n  const newNum = 21;\n  jest.setSystemTime(new Date(2022, 1, 10, 0, 0, 0).getTime());\n  currentHashed = encodeHashed(newNum, \"refresh\", \"latest\");\n  rerender();\n  expect(decode(result.current.hash)).toBe(\"refresh\");\n  expect(result.current.num).toBe(newNum);\n});\n"
  },
  {
    "path": "utils/animation.ts",
    "content": "import showConfetti from \"canvas-confetti\";\n\nexport default function confetti(duration: number = 3) {\n  let animationFrame: any;\n  const end = Date.now() + duration * 1000;\n\n  function frame() {\n    // launch a few confetti from the left edge\n    showConfetti({\n      particleCount: 7,\n      angle: 60,\n      spread: 55,\n      origin: { x: 0 },\n    });\n    // and launch a few from the right edge\n    showConfetti({\n      particleCount: 7,\n      angle: 120,\n      spread: 55,\n      origin: { x: 1 },\n    });\n\n    // keep going until we are out of time\n    if (Date.now() < end) {\n      animationFrame = requestAnimationFrame(frame);\n    }\n  }\n\n  frame();\n  return () => cancelAnimationFrame(animationFrame);\n}\n"
  },
  {
    "path": "utils/answers.ts",
    "content": "import fs from \"fs/promises\";\nimport path from \"path\";\n\nexport function getAllAnswers() {\n  return fs\n    .readFile(path.join(process.cwd(), \"./.scripts/answers.csv\"), \"utf8\")\n    .then((text) =>\n      text\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    );\n}\n"
  },
  {
    "path": "utils/browser.ts",
    "content": "import Alert from \"../components/Alert\";\n\nexport function checkNativeShareSupport() {\n  const isFirefox = navigator.userAgent.toLowerCase().indexOf(\"firefox\") > -1;\n  const isAndroid = navigator.userAgent.toLowerCase().indexOf(\"android\") > -1;\n\n  if (isFirefox && isAndroid) {\n    return false;\n  }\n\n  const isDesktop =\n    window.screenX === 0 &&\n    !(\"ontouchstart\" in window) &&\n    // https://sentry.io/share/issue/5faab0d5e08a4d02a32cace759e7e3d8/\n    (screen?.orientation?.type ?? \"landscape-primary\") === \"landscape-primary\";\n\n  if (isDesktop) {\n    return false;\n  }\n\n  return \"share\" in navigator;\n}\n\n/**\n * Safe & drop-in replacement for localStorage access for browser with storage disabled\n *  - Firefox with dom.storage.enabled = false\n *  - Webview with storage disabled\n */\nconst LocalStorage: typeof window.localStorage = {\n  getItem(key: string): string | null {\n    try {\n      const value = window.localStorage.getItem(key);\n      if (value === \"undefined\" || !value) {\n        return null;\n      }\n      return value;\n    } catch (err) {\n      return null;\n    }\n  },\n  setItem(key: string, value: string): void {\n    try {\n      return window.localStorage.setItem(key, value);\n    } catch (err) {\n      return;\n    }\n  },\n  removeItem(key: string): void {\n    try {\n      return window.localStorage.removeItem(key);\n    } catch (err) {\n      return;\n    }\n  },\n  clear(): void {\n    try {\n      return window.localStorage.clear();\n    } catch (err) {\n      return;\n    }\n  },\n  key(index: number): string | null {\n    try {\n      return window.localStorage.key(index);\n    } catch (err) {\n      return null;\n    }\n  },\n  get length(): number {\n    try {\n      return window.localStorage.length;\n    } catch (err) {\n      return 0;\n    }\n  },\n};\n\nexport default LocalStorage;\n\nexport function isStorageEnabled() {\n  const randomKey = Math.random().toFixed(5);\n  const randomValue = Math.random().toFixed(5);\n  const storageKey = `katla:test:${randomKey}`;\n  LocalStorage.setItem(storageKey, randomValue);\n  const stored = LocalStorage.getItem(storageKey);\n  if (stored === null) {\n    return false;\n  }\n\n  LocalStorage.removeItem(storageKey);\n  return stored === randomValue;\n}\n\ninterface ShareOptions {\n  cb?: () => void;\n  fallbackText?: string;\n  clipboardSuccessMessage?: string;\n}\n\nexport function shareLink(url: string, options: ShareOptions) {\n  share({ url }, { ...options, fallbackText: url });\n}\n\nexport function shareText(text: string, options: ShareOptions) {\n  share({ text }, { ...options, fallbackText: text });\n}\n\nexport function share(data: ShareData, options: ShareOptions) {\n  const useNativeShare = checkNativeShareSupport();\n  const clipboardSuccessCallback = () => {\n    const message = options.clipboardSuccessMessage ?? \"Disalin ke clipboard\";\n    options.cb?.();\n    Alert.show(message, { id: \"clipboard \" });\n  };\n  const clipboardFailedCallback = (_: Error) => {\n    options.cb?.();\n    Alert.show(\"Gagal menyalin ke clipboard\", { id: \"clipboard\" });\n  };\n\n  if (useNativeShare) {\n    // native share\n    navigator.share(data).catch(() => {\n      // TODO: handle non abort error\n    });\n  } else if (\n    \"clipboard\" in navigator &&\n    typeof navigator.clipboard.writeText === \"function\" &&\n    // https://sentry.io/share/issue/5074ad1fa6b34a2a9985edc7155967f0/\n    // https://stackoverflow.com/questions/61243646/clipboard-api-call-throws-notallowederror-without-invoking-onpermissionrequest\n    \"permissions\" in navigator\n  ) {\n    // async clipboard API\n    const promise = navigator.clipboard.writeText(options.fallbackText);\n\n    // https://sentry.io/share/issue/59a42dfd516a439a99f763ee276aff26/\n    if (promise) {\n      promise.then(clipboardSuccessCallback).catch(clipboardFailedCallback);\n    }\n  } else {\n    // legacy browsers without async clipboard API support\n    const textarea = document.createElement(\"textarea\");\n    textarea.textContent = options.fallbackText;\n    textarea.style.position = \"fixed\";\n    document.body.appendChild(textarea);\n    // https://sentry.io/share/issue/cb8a0ca8f6fc47858eafe4bc5959debd/\n    textarea.focus();\n    textarea.select();\n    try {\n      document.execCommand(\"copy\");\n      clipboardSuccessCallback();\n    } catch (err) {\n      clipboardFailedCallback(err);\n    } finally {\n      document.body.removeChild(textarea);\n    }\n  }\n}\n"
  },
  {
    "path": "utils/codec.ts",
    "content": "export function encode(word: string): string {\n  const base64 = Buffer.from(word).toString(\"base64\");\n  const equalSigns = base64.split(\"\").filter((char) => char === \"=\").length;\n  const withoutEq = base64.replace(/=/g, \"\");\n  let newStr = \"\";\n  for (let i = 0; i < withoutEq.length; i++) {\n    newStr += String.fromCharCode(\n      withoutEq.charCodeAt(i) + (i % 2 === 0 ? 1 : -1)\n    );\n  }\n\n  return newStr + equalSigns;\n}\n\nexport function decode(hash: string): string {\n  const [equalSigns, ...chars] = hash.split(\"\").reverse();\n  const padding = \"=\".repeat(Number(equalSigns));\n  const base64 =\n    chars\n      .reverse()\n      .map((str, i) => {\n        const charCode = str.charCodeAt(0) + (i % 2 === 0 ? -1 : 1);\n        return String.fromCharCode(charCode);\n      })\n      .join(\"\") + padding;\n  return Buffer.from(base64, \"base64\").toString();\n}\n\nconst HASHED_SEPARATOR = \"::\";\n\nexport function encodeHashed(\n  num: number,\n  latestAnswer: string,\n  previousAnswer: string\n) {\n  return encode(\n    [num, encode(latestAnswer), encode(previousAnswer)].join(HASHED_SEPARATOR)\n  );\n}\n\nexport function decodeHashed(hashed: string) {\n  return decode(hashed).split(HASHED_SEPARATOR);\n}\n"
  },
  {
    "path": "utils/constants.ts",
    "content": "export const LAST_HASH_KEY = \"katla:lastHash\";\nexport const LAST_SESSION_RESET_KEY = \"katla:lastSessionReset\";\nexport const GAME_LIVE_STATE_KEY = \"katla:liveGameState\";\nexport const GAME_STATE_KEY = \"katla:gameState\";\nexport const GAME_STATS_KEY = \"katla:gameStats\";\nexport const INVALID_WORDS_KEY = \"katla:invalidWords\";\n\n// animation\nexport const SHAKE_ANIMATION_DURATION_MS = 600;\nexport const FLIP_ANIMATION_DURATION_MS = 800;\nexport const FLIP_ANIMATION_DELAY_MS = 400;\n"
  },
  {
    "path": "utils/fetcher.ts",
    "content": "const fetcher = (...args: Parameters<typeof fetch>) =>\n  fetch(...args).then((res) => res.json());\n\nexport default fetcher;\n"
  },
  {
    "path": "utils/formatter.ts",
    "content": "export function formatDate(d: Date) {\n  const year = d.getFullYear();\n  const month = d.getMonth() + 1;\n  const date = d.getDate();\n  return `${year}-${pad0(month)}-${pad0(date)}`;\n}\n\nexport function formatTime(d: Date) {\n  const hours = d.getHours();\n  const minutes = d.getMinutes();\n  const seconds = d.getSeconds();\n  return `${hours}:${pad0(minutes)}:${pad0(seconds)}`;\n}\n\nexport function pad0(n: number): string {\n  return n.toString().padStart(2, \"0\");\n}\n"
  },
  {
    "path": "utils/game.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { useRouter } from \"next/router\";\n\nimport { LAST_HASH_KEY, GAME_STATE_KEY, INVALID_WORDS_KEY } from \"./constants\";\nimport {\n  AnswerState,\n  Game,\n  GameState,\n  GameStats,\n  MigrationData,\n} from \"./types\";\nimport LocalStorage, { isStorageEnabled } from \"./browser\";\nimport createStoredState from \"./useStoredState\";\nimport { trackEvent } from \"./tracking\";\nimport { decode, decodeHashed } from \"./codec\";\nimport { unstable_batchedUpdates } from \"react-dom\";\n\nexport const initialState: GameState = {\n  answers: Array(6).fill(\"\"),\n  attempt: 0,\n  lastCompletedDate: null,\n  enableHighContrast: false,\n  enableHardMode: false,\n  enableFreeEdit: false,\n  enableLiarMode: false,\n  lieBoxes: [],\n};\n\nexport const useGamePersistedState =\n  createStoredState<GameState>(GAME_STATE_KEY);\n\nexport function useGame(hashed: string, enableStorage: boolean = true): Game {\n  const useGameState = enableStorage ? useGamePersistedState : useState;\n  const [state, setState] = useGameState<GameState>(initialState);\n  const [readyState, setGameReadyState] = useState<Game[\"readyState\"]>(\"init\");\n  const router = useRouter();\n\n  const [num, latestHash, previousHash] = decodeHashed(hashed);\n  const initialCurrentNum = Number(num);\n  const [currentNum, setCurrentNum] = useState(initialCurrentNum);\n  const [currentHash, setCurrentHash] = useState(latestHash);\n\n  useEffect(() => {\n    if (!enableStorage) {\n      setGameReadyState(\"ready\");\n      return;\n    }\n\n    if (isStorageEnabled()) {\n      setGameReadyState(\"ready\");\n    } else {\n      setGameReadyState(\"no-storage\");\n      return;\n    }\n\n    // check for new game schedule\n    const now = new Date();\n    const gameDate = new Date(\"2022-01-20\");\n    gameDate.setDate(gameDate.getDate() + initialCurrentNum);\n    gameDate.setHours(0);\n    gameDate.setMinutes(0);\n    gameDate.setSeconds(0);\n    gameDate.setMilliseconds(0);\n    const isAfterGameDate = now.getTime() >= gameDate.getTime();\n\n    const lastHash = LocalStorage.getItem(LAST_HASH_KEY);\n\n    // first time playing\n    if (!lastHash) {\n      if (!isAfterGameDate) {\n        unstable_batchedUpdates(() => {\n          setCurrentHash(previousHash);\n          setCurrentNum(initialCurrentNum - 1);\n        });\n        LocalStorage.setItem(LAST_HASH_KEY, previousHash);\n        return;\n      }\n\n      LocalStorage.setItem(LAST_HASH_KEY, currentHash);\n      return;\n    }\n\n    // already play\n    if (lastHash !== latestHash) {\n      // ready for a new game\n      if (isAfterGameDate) {\n        unstable_batchedUpdates(() => {\n          setCurrentHash(latestHash);\n          setCurrentNum(initialCurrentNum);\n          setState((state) => ({\n            ...state,\n            answers: Array(6).fill(\"\"),\n            attempt: 0,\n            // always reset liar mode\n            enableLiarMode: false,\n            lieBoxes: [],\n          }));\n        });\n        LocalStorage.setItem(LAST_HASH_KEY, latestHash);\n        LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));\n      } else if (lastHash !== previousHash) {\n        // last hash is not in hashed\n        unstable_batchedUpdates(() => {\n          setCurrentHash(previousHash);\n          setCurrentNum(initialCurrentNum - 1);\n          setState((state) => ({\n            ...state,\n            answers: Array(6).fill(\"\"),\n            attempt: 0,\n            // always reset liar mode\n            enableLiarMode: false,\n            lieBoxes: [],\n          }));\n        });\n        LocalStorage.setItem(LAST_HASH_KEY, previousHash);\n        LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));\n      } else {\n        unstable_batchedUpdates(() => {\n          setCurrentHash(previousHash);\n          setCurrentNum(initialCurrentNum - 1);\n        });\n      }\n    }\n    // we want this effect to execute only once on mount\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hashed]);\n\n  function migrate(hash: string, state: GameState) {\n    unstable_batchedUpdates(() => {\n      setCurrentHash(hash);\n      setState(state);\n    });\n  }\n\n  function trackInvalidWord(word: string) {\n    let invalidWords = [];\n    try {\n      invalidWords = JSON.parse(localStorage.getItem(INVALID_WORDS_KEY));\n      if (!Array.isArray(invalidWords)) {\n        throw new Error(\"invalid words is not an array\");\n      }\n    } catch (err) {\n      invalidWords = [];\n    }\n\n    if (invalidWords.includes(word)) {\n      return;\n    }\n\n    invalidWords.push(word);\n    LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify(invalidWords));\n    trackEvent(\"invalid_word\", { word });\n  }\n\n  useEffect(() => {\n    if (state.enableHighContrast) {\n      document.documentElement.setAttribute(\"data-katla-hc\", \"true\");\n    } else {\n      document.documentElement.removeAttribute(\"data-katla-hc\");\n    }\n  }, [state.enableHighContrast]);\n\n  useEffect(() => {\n    function handleVisibilityChange() {\n      if (document.visibilityState === \"visible\") {\n        router.replace(router.asPath);\n      }\n    }\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n  }, [router]);\n\n  return {\n    hash: currentHash,\n    num: currentNum,\n    migrate,\n    readyState,\n    ready: readyState !== \"init\",\n    state,\n    setState,\n    trackInvalidWord,\n  };\n}\n\nexport function useRemainingTime() {\n  const now = new Date();\n  const hours = getHoursDiff(now);\n  const minutes = getMinutesDiff(now);\n  const seconds = getSecondsDiff(now);\n  const router = useRouter();\n\n  const [remainingTime, setRemainingTime] = useState({\n    hours,\n    minutes,\n    seconds,\n  });\n\n  useEffect(() => {\n    const t = setInterval(() => {\n      const now = new Date();\n      const hours = getHoursDiff(now);\n      const minutes = getMinutesDiff(now);\n      const seconds = getSecondsDiff(now);\n\n      if (hours + minutes + seconds === 0) {\n        router.replace(router.asPath);\n      }\n\n      setRemainingTime({ hours, minutes, seconds });\n    }, 100);\n    return () => clearInterval(t);\n  }, [router]);\n\n  return remainingTime;\n}\n\nfunction getHoursDiff(date: Date) {\n  return date.getHours() === 0 &&\n    date.getMinutes() === 0 &&\n    date.getSeconds() === 0\n    ? 0\n    : 23 - date.getHours();\n}\n\nfunction getMinutesDiff(date: Date) {\n  return date.getMinutes() === 0\n    ? 0\n    : (date.getSeconds() === 0 ? 60 : 59) - date.getMinutes();\n}\n\nfunction getSecondsDiff(date: Date) {\n  return date.getSeconds() === 0 ? 0 : 60 - date.getSeconds();\n}\n\nexport function isGameFinished(game: Game) {\n  return (\n    game.state.attempt === 6 ||\n    game.state.answers[game.state.attempt - 1] === decode(game.hash)\n  );\n}\n\nexport function getTotalWin(stats: GameStats) {\n  const { fail, ...wins } = stats.distribution;\n  const totalWin = Object.values(wins).reduce((a, b) => a + b, 0);\n  return totalWin;\n}\n\nexport function getTotalPlay(stats: GameStats) {\n  return getTotalWin(stats) + stats.distribution.fail;\n}\n\nexport function verifyStreak(lastCompletedDate: number | null): boolean {\n  if (!lastCompletedDate) {\n    return true;\n  }\n\n  const lastDate = new Date(lastCompletedDate);\n  const now = new Date();\n  now.setDate(now.getDate() - 1);\n  return (\n    now.getDate() === lastDate.getDate() &&\n    now.getMonth() === lastDate.getMonth() &&\n    now.getFullYear() === lastDate.getFullYear()\n  );\n}\n\ntype AnswerStates = [\n  AnswerState,\n  AnswerState,\n  AnswerState,\n  AnswerState,\n  AnswerState\n];\n\nexport function getAnswerStates(\n  userAnswer: string,\n  answer: string\n): AnswerStates {\n  const states: AnswerStates = Array(5).fill(null) as any;\n\n  const answerChars = answer.split(\"\");\n  const userAnswerChars = userAnswer.split(\"\");\n  for (let i = 0; i < answerChars.length; i++) {\n    if (userAnswer[i] === answerChars[i]) {\n      states[i] = \"c\";\n      answerChars[i] = null;\n      userAnswerChars[i] = null;\n    }\n  }\n\n  for (let i = 0; i < userAnswerChars.length; i++) {\n    if (userAnswerChars[i] === null) {\n      continue;\n    }\n\n    const answerIndex = answerChars.indexOf(userAnswer[i]);\n    if (answerIndex !== -1) {\n      states[i] = \"e\";\n      answerChars[answerIndex] = null;\n      userAnswerChars[i] = null;\n    }\n  }\n\n  return states.map((s) => (s === null ? \"w\" : s)) as any;\n}\n\nexport function checkHardModeAnswer(\n  state: GameState,\n  answer: string\n): [isInvalid: boolean, unusedChar: string, letterIndex?: number] {\n  const previousAnswer = state.answers[state.attempt - 1];\n  const currentAnswer = state.answers[state.attempt];\n\n  const previousAnswerStates = getAnswerStates(previousAnswer, answer);\n  const currentAnswerStates = getAnswerStates(currentAnswer, answer);\n\n  // first check for unused characters\n  const mustBeUsedChars: string[] = previousAnswerStates.flatMap((state, i) => {\n    if (state === \"e\") {\n      return previousAnswer[i];\n    }\n    return [];\n  });\n\n  for (let i = 0; i < mustBeUsedChars.length; i++) {\n    if (!currentAnswer.includes(mustBeUsedChars[i])) {\n      return [true, mustBeUsedChars[i].toUpperCase()];\n    }\n  }\n\n  // then check for matching answer\n  for (let i = 0; i < previousAnswerStates.length; i++) {\n    if (previousAnswerStates[i] === \"c\" && currentAnswerStates[i] !== \"c\") {\n      return [true, previousAnswer[i].toUpperCase(), i + 1];\n    }\n  }\n\n  return [false, \"\"];\n}\n\nexport function generateMigrationLink(hash: string, stats: GameStats): string {\n  const migrationData: MigrationData = {\n    stats,\n    lastHash: hash,\n    time: Date.now(),\n  };\n  const encodedMigrationData = encodeURIComponent(\n    JSON.stringify(migrationData)\n  );\n\n  return `https://katla.id/?migrate=${encodedMigrationData}`;\n}\n"
  },
  {
    "path": "utils/liveGame.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport {\n  useBroadcastEvent,\n  useEventListener,\n  useList,\n  useOthers,\n  useRoom,\n  useSelf,\n  useUpdateMyPresence,\n} from \"@liveblocks/react\";\n\nimport { Game, GameState, LiveConfig, LiveEvent } from \"./types\";\nimport { GAME_LIVE_STATE_KEY } from \"./constants\";\nimport createStoredState from \"./useStoredState\";\nimport LocalStorage, { isStorageEnabled, shareLink } from \"./browser\";\nimport { getAnswerStates, initialState as gameInitialState } from \"./game\";\nimport { decode, encode } from \"./codec\";\nimport { rainEmoji } from \"../components/EmojiRain\";\nimport confetti from \"./animation\";\nimport Alert from \"../components/Alert\";\n\nexport interface LiveGameState extends GameState {\n  winCount: number;\n}\n\nexport interface LiveGame extends Game<LiveGameState> {\n  start: () => void;\n}\n\nconst initialState: LiveGameState = {\n  ...gameInitialState,\n  winCount: 0,\n};\n\nconst useGameState = createStoredState<LiveGameState>(GAME_LIVE_STATE_KEY);\nexport const defaultScore = Array(5).fill(0);\n\nconst NEW_GAME_DELAY_MS = 5000;\n\nexport function useLiveGame(words: string[]): LiveGame {\n  const [state, setState] = useGameState(initialState);\n  const [readyState, setGameReadyState] = useState<Game[\"readyState\"]>(\"init\");\n  const hashes = useList<string>(\"hashes\", []);\n  const [num, setNum] = useState(-1);\n  const [hash, setHash] = useState(\"\");\n  const updateMyPresence = useUpdateMyPresence();\n  const broadcast = useBroadcastEvent();\n  const self = useSelf();\n  const others = useOthers();\n  const room = useRoom();\n\n  useEffect(() => {\n    if (isStorageEnabled()) {\n      setGameReadyState(\"ready\");\n    } else {\n      setGameReadyState(\"no-storage\");\n      return;\n    }\n  }, []);\n\n  useEventListener(({ event }: { event: LiveEvent }) => {\n    switch (event.type) {\n      case \"emoji\": {\n        Alert.show(`Pesan dari: ${event.username}`, { id: \"emoji\" });\n        rainEmoji(event.emoji);\n        break;\n      }\n      case \"win\": {\n        handleWin(event.username);\n        break;\n      }\n      case \"lose\": {\n        handleLose(event.answer);\n        break;\n      }\n      case \"start\": {\n        handleStart();\n        break;\n      }\n    }\n  });\n\n  function submitAnswer(answer: string, attempt: number) {\n    const scores = getUserScores(answer, hash);\n\n    const totalScore = getTotalScore(scores);\n    if (totalScore === 5) {\n      room.batch(() => {\n        handleWin(self.id);\n        broadcast({ type: \"win\", username: self.id });\n        updateMyPresence({ winCount: (self.presence?.winCount ?? 0) + 1 });\n      });\n\n      setTimeout(() => {\n        room.batch(() => {\n          startNewGame();\n          handleStart();\n        });\n      }, NEW_GAME_DELAY_MS);\n    }\n\n    const isFailed = attempt === 6 && totalScore !== 5;\n    if (isFailed && others.toArray().every((user) => user.presence?.isFailed)) {\n      const answer = decode(hash);\n      room.batch(() => {\n        handleLose(answer);\n        broadcast({ type: \"lose\", answer });\n      });\n\n      setTimeout(() => {\n        room.batch(() => {\n          startNewGame();\n          handleStart();\n        });\n      }, NEW_GAME_DELAY_MS);\n    }\n\n    updateMyPresence({ scores, isFailed });\n  }\n\n  function handleWin(username: string) {\n    confetti();\n    Alert.show(\n      `Selamat, ${username}!!\\nRonde selanjutnya akan dimulai dalam 5 detik`,\n      { id: \"answer\", duration: NEW_GAME_DELAY_MS }\n    );\n  }\n\n  function handleLose(answer: string) {\n    rainEmoji(\"💀\");\n    Alert.show(\n      `Jawaban: ${answer}.\\nRonde selanjutnya akan dimulai dalam 5 detik`,\n      { id: \"answer\", duration: NEW_GAME_DELAY_MS }\n    );\n  }\n\n  function handleStart() {\n    updateMyPresence({ scores: defaultScore, isFailed: false });\n    setState((state) => ({\n      ...initialState,\n      winCount: state.winCount,\n    }));\n  }\n\n  function startNewGame() {\n    const unusedWords = words.filter(\n      (word) => !hashes.find((hash) => hash === encode(word))\n    );\n    const word = unusedWords[Math.floor(Math.random() * unusedWords.length)];\n\n    hashes.push(encode(word));\n    broadcast({ type: \"start\" });\n  }\n\n  function resetState() {\n    room.batch(() => {\n      hashes.clear();\n      startNewGame();\n      handleStart();\n    });\n  }\n\n  useEffect(() => {\n    if (!hashes) {\n      return;\n    }\n\n    function handleSubscribe() {\n      const hash = hashes.get(hashes.length - 1);\n      setNum(hashes.length);\n      setHash(hash);\n    }\n\n    handleSubscribe();\n    const unsubscribe = room.subscribe(hashes, handleSubscribe);\n    return () => unsubscribe();\n  }, [hashes, room]);\n\n  function start() {\n    room.batch(() => {\n      startNewGame();\n      handleStart();\n    });\n  }\n\n  useEffect(() => {\n    if (!hash) {\n      return;\n    }\n\n    try {\n      const storedState: LiveGameState = JSON.parse(\n        LocalStorage.getItem(GAME_LIVE_STATE_KEY)\n      );\n      if (storedState.attempt > 0 && !self.presence?.scores) {\n        const scores = getUserScores(\n          storedState.answers[storedState.attempt - 1],\n          hash\n        );\n        const totalScore = getTotalScore(scores);\n        const isFailed = storedState.attempt === 6 && totalScore !== 5;\n        updateMyPresence({ scores, isFailed, winCount: storedState.winCount });\n      }\n    } catch (_) {}\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return {\n    hash,\n    num,\n    migrate: () => {},\n    ready: readyState === \"ready\" && !!hashes,\n    readyState,\n    state,\n    setState,\n    trackInvalidWord: () => {},\n    submitAnswer,\n    resetState,\n    start,\n  };\n}\n\nexport function generateRoomId(auth: string) {\n  const id = \"xxxxxxxxxxxxxxxxxxxxx\".replace(/[x]/g, () => {\n    return ((Math.random() * 16) | 0).toString(16);\n  });\n\n  return \"kt-\" + auth + \"-\" + id;\n}\n\ntype Scores = Array<number>;\n\nfunction getUserScores(answer: string, hash: string): Scores {\n  return getAnswerStates(answer, decode(hash)).map((state) => {\n    switch (state) {\n      case \"c\":\n        return 1;\n      case \"e\":\n        return 0.5;\n      case \"w\":\n        return 0;\n    }\n  });\n}\n\nexport function getTotalScore(scores: Scores) {\n  return scores.reduce((sum, state) => sum + state, 0);\n}\n\nexport function getEmojiFromScore(\n  score: number,\n  darkMode: boolean,\n  highContrast: boolean\n) {\n  switch (score) {\n    case 0:\n      return darkMode ? \"⬜️\" : \"⬛\";\n    case 1:\n      return highContrast ? \"🟧\" : \"🟩\";\n    case 0.5:\n      return highContrast ? \"🟦\" : \"🟨\";\n  }\n}\n\nexport function shareInviteLink(config: LiveConfig, cb?: () => void) {\n  const host = window.location.protocol + \"//\" + window.location.host;\n  const url = `${host}/lawan?room=${config.roomId}&invite=${config.inviteKey}`;\n  shareLink(url, {\n    cb,\n    clipboardSuccessMessage: \"Tautan telah disalin ke clipboard\",\n  });\n}\n"
  },
  {
    "path": "utils/message.ts",
    "content": "import { getTotalPlay } from \"./game\";\nimport { Game, AnswerState, GameStats } from \"./types\";\nimport confetti from \"./animation\";\nimport { encode } from \"./codec\";\nimport { rainEmoji } from \"../components/EmojiRain\";\nimport Alert from \"../components/Alert\";\n\nexport function getCongratulationMessage(attempt: number, stats: GameStats) {\n  const totalPlay = getTotalPlay(stats);\n  const { fail, ...rest } = stats.distribution;\n\n  if (totalPlay === 0 && attempt === 1) {\n    return randomElement([\"Browser baru?\", \"Baru main? 👏👏\"]);\n  }\n\n  const message1 = [\n    \"Hoki? Atau kena spoiler\",\n    \"Wow\",\n    \"Jenius\",\n    \"Ajaib\",\n    \"Cenayang\",\n    \"Si Peramal\",\n    \"Penjelajah Waktu\",\n  ];\n  const message2 = [\n    \"Kebanggaan Negara\",\n    \"Kesayangan Ibu Pertiwi\",\n    \"Sakti Mandraguna\",\n    \"Cendekiawan\",\n    \"Kaum Intelek\",\n  ];\n  const message3 = [\n    \"Luar Biasa!\",\n    \"Jagoan!\",\n    \"Mantap!\",\n    \"Cerdas!\",\n    \"Keren!\",\n    \"Salut!\",\n    \"Hebat!\",\n    \"Lantip!\",\n    \"Brilian!\",\n    \"Otak Cemerlang\",\n  ];\n  const message4 = [\n    \"Bagus Sekali\",\n    \"Cermat\",\n    \"Pintar\",\n    \"Teladan\",\n    \"Idaman\",\n    \"Cerdik\",\n    \"Encer\",\n  ];\n  const message5 = [\"Bagus\", \"Horee\", \"Selamat!\", \"Pandai\"];\n  const message6 = [\"Nyaris!!!\", \"Hampir saja\", \"Lega!!\"];\n\n  if (stats.distribution[6] + fail > totalPlay / 3) {\n    message6.push(\"Hobi amat mepet\", \"Suka angka 6?\");\n  }\n\n  if (totalPlay > 7 && fail > totalPlay / 3) {\n    message6.push(\"Hampir dideportasi\");\n  }\n\n  if (totalPlay > 7) {\n    message4.push(\"4 Sehat\", \"Dikit lagi Keren!\");\n    message5.push(\"5 sempurna\", \"Tidak buruk\", \"Okelah\");\n    message6.push(\"Mepet!\");\n\n    if (stats.distribution[6] > 3) {\n      message6.push(\"Tetap semangat!\", \"Mungkin besok lebih baik\");\n    }\n\n    if (stats.distribution[attempt] === 0) {\n      const bestMove = Object.values(rest).findIndex((value) => value > 0) + 1;\n      if (attempt < bestMove && stats.distribution[bestMove] > 3) {\n        return \"Akhirnyaaa 🥳\";\n      }\n    }\n  }\n\n  switch (attempt) {\n    case 1:\n      return randomElement(message1);\n    case 2:\n      return randomElement(message2);\n    case 3:\n      return randomElement(message3);\n    case 4:\n      return randomElement(message4);\n    case 5:\n      return randomElement(message5);\n    default:\n      return randomElement(message6);\n  }\n}\n\nexport function getFailureMessage(\n  stats: GameStats,\n  answerStates: AnswerState[]\n) {\n  const messageFail = [\"Sayang sekali\"];\n\n  if (answerStates.filter((state) => state === \"c\").length === 4) {\n    messageFail.push(\"Sabar ya\", \"Dikiit lagi 🤏\", \"Upss\");\n  }\n\n  if (stats.distribution.fail > 1) {\n    messageFail.push(\n      \"Jangan menyerah\",\n      \"Coba lagi besok\",\n      \"Masih belum beruntung\"\n    );\n  }\n\n  if (stats.distribution.fail > 3) {\n    messageFail.push(\"Belajar lagi\");\n  }\n\n  return randomElement(messageFail);\n}\n\nfunction randomElement<T>(array: T[]): T {\n  const index = Math.floor(Math.random() * array.length);\n  return array[index];\n}\n\nconst LOVE_HASHES = [\n  \"Z1mteFF1\",\n  \"b1GybVh1\",\n  \"d1W/bVF1\",\n  \"dl:sZV51\",\n  \"bV6jZVh1\",\n  \"ZmWt[1F1\",\n  \"clmqZVh1\",\n  \"blGtbll1\",\n  \"eGWreWN1\",\n  \"dlmt[GV1\",\n];\n\nconst EID_HASHES = [\n  \"cV:nc151\",\n  \"cFGnbWJ1\",\n  \"ZlG/bV51\",\n  \"[lm/dll1\",\n  \"dGWgd1F1\",\n  \"d1GrZWR1\",\n  \"flGqZWR1\",\n  \"[V2ud1l1\",\n  \"bFmrZVx1\",\n  \"bV6yZVZ1\",\n  \"d1GhZWJ1\",\n  \"eGWreWN1\",\n];\n\nexport function handleSubmitWord(game: Game, userAnswer: string) {\n  if (game.num === 25 && LOVE_HASHES.includes(encode(userAnswer))) {\n    const loveEmojis = [\"💖\", \"💗\", \"💘\", \"💙\", \"💚\", \"💛\", \"💜\", \"💝\"];\n    const emoji = loveEmojis[Math.floor(Math.random() * loveEmojis.length)];\n    return rainEmoji(emoji);\n  }\n\n  if (game.num === 102 && EID_HASHES.includes(encode(userAnswer))) {\n    return rainEmoji(\"🙏\");\n  }\n}\n\ninterface GameCompleteOptions {\n  hash: string;\n  attempt: number;\n  stats: GameStats;\n  cb?: () => void;\n}\n\nexport function handleGameComplete(options: GameCompleteOptions) {\n  const { hash, attempt, stats, cb } = options;\n  const message = getCongratulationMessage(attempt, stats);\n  Alert.show(message, {\n    id: \"finish\",\n    duration: 1250,\n    cb,\n  });\n\n  if (hash === \"eVy/ZVh1\") {\n    return confetti();\n  }\n\n  if (hash === \"ZlWxZVt1\" && attempt === 1) {\n    return rainEmoji(\"💩\");\n  }\n}\n\nexport function isEidMessage(answer: string) {\n  return EID_HASHES.includes(encode(answer));\n}\n"
  },
  {
    "path": "utils/tracking.ts",
    "content": "declare global {\n  interface Window {\n    gtag: (...args: any[]) => void;\n  }\n}\n\nexport function trackEvent(\n  eventName: string,\n  args: Record<string, string | number>\n) {\n  if (\"gtag\" in window && typeof window.gtag === \"function\") {\n    window.gtag(\"event\", eventName, args);\n  }\n}\n"
  },
  {
    "path": "utils/types.ts",
    "content": "import { Dispatch, SetStateAction } from \"react\";\n\nexport type AnswerState = \"c\" | \"e\" | \"w\";\nexport type ForcedResult = [column: number, state: AnswerState];\n\nexport interface GameState {\n  answers: string[];\n  attempt: number;\n  lastCompletedDate: number | null;\n  enableHighContrast: boolean;\n  enableHardMode: boolean;\n  enableFreeEdit: boolean;\n  enableLiarMode: boolean;\n  lieBoxes: ForcedResult[];\n}\n\nexport interface GameStats {\n  distribution: {\n    1: number;\n    2: number;\n    3: number;\n    4: number;\n    5: number;\n    6: number;\n    fail: number;\n  };\n  currentStreak: number;\n  maxStreak: number;\n}\n\nexport interface Game<T = GameState> {\n  hash: string;\n  num: number;\n  readyState: \"init\" | \"no-storage\" | \"ready\";\n  ready: boolean;\n  state: T;\n  migrate: (lastHash: string, state: T) => void;\n  setState: Dispatch<SetStateAction<T>>;\n  trackInvalidWord?: (word: string) => void;\n  submitAnswer?: (answer: string, attempt: number) => void;\n  resetState?: () => void;\n}\n\nexport interface MigrationData {\n  stats: GameStats;\n  lastHash: string;\n  time: number;\n}\n\nexport interface LiveConfig {\n  isHost: boolean;\n  roomId: string;\n  inviteKey: string;\n}\n\ninterface StartEvent {\n  type: \"start\";\n  hash: string;\n  num: number;\n}\n\ninterface EmojiEvent {\n  type: \"emoji\";\n  emoji: string;\n  username: string;\n}\n\ninterface WinEvent {\n  type: \"win\";\n  username: string;\n}\n\ninterface LoseEvent {\n  type: \"lose\";\n  answer: string;\n}\n\nexport type LiveEvent = StartEvent | EmojiEvent | WinEvent | LoseEvent;\n"
  },
  {
    "path": "utils/useStoredState.ts",
    "content": "import { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport LocalStorage from \"./browser\";\n\nexport default function createStoredState<T>(storageKey: string) {\n  return function useStoredState(initialState: T): [T, Dispatch<SetStateAction<T>>] {\n    const [state, setState] = useState<T>(initialState);\n\n    function setStoredState(state: T) {\n      LocalStorage.setItem(storageKey, JSON.stringify(state));\n      setState(state);\n    }\n\n    useEffect(() => {\n      function updateStateFromStorage() {\n        try {\n          const storedState = LocalStorage.getItem(storageKey);\n          if (storedState) {\n            const parsedState = JSON.parse(storedState);\n            setState(parsedState);\n          }\n        } catch (_) {}\n      }\n\n      updateStateFromStorage();\n      window.addEventListener(\"focus\", updateStateFromStorage);\n      return () => window.removeEventListener(\"focus\", updateStateFromStorage);\n    }, []);\n\n    return [state, setStoredState];\n  };\n}\n"
  }
]