Repository: pveyes/katla
Branch: main
Commit: a5195dfc488c
Files: 69
Total size: 176.7 KB
Directory structure:
gitextract_1iyklgqu/
├── .eslintrc.json
├── .github/
│ └── workflows/
│ ├── test.yml
│ └── update.yml
├── .gitignore
├── .husky/
│ └── pre-commit
├── .prettierignore
├── .scripts/
│ ├── answers.csv
│ ├── update.js
│ └── whitelist.csv
├── .vscode/
│ └── settings.json
├── README.md
├── components/
│ ├── Alert.tsx
│ ├── App.tsx
│ ├── Board.tsx
│ ├── Container.tsx
│ ├── EmojiRain.tsx
│ ├── EmojiSelector.tsx
│ ├── Header.tsx
│ ├── HeadingWithNum.tsx
│ ├── HelpModal.tsx
│ ├── Keyboard.tsx
│ ├── KeyboardButton.tsx
│ ├── Link.tsx
│ ├── LiveStatsModal.tsx
│ ├── Modal.tsx
│ ├── SettingsModal.tsx
│ ├── SponsorshipFooter.tsx
│ ├── StatsModal.tsx
│ └── Tile.tsx
├── env.dev
├── jest.config.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages/
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── _error.js
│ ├── api/
│ │ ├── define/
│ │ │ └── [word].ts
│ │ ├── live.ts
│ │ └── words.ts
│ ├── arsip/
│ │ ├── [num].tsx
│ │ └── index.tsx
│ ├── bantuan.tsx
│ ├── index.tsx
│ └── lawan.tsx
├── postcss.config.js
├── public/
│ ├── ads.txt
│ └── google563f40b6a67a6d75.html
├── sentry.client.config.js
├── sentry.properties
├── sentry.server.config.js
├── styles/
│ └── globals.css
├── tailwind.config.js
├── tsconfig.json
└── utils/
├── __tests__/
│ ├── answer.test.js
│ └── game.test.js
├── animation.ts
├── answers.ts
├── browser.ts
├── codec.ts
├── constants.ts
├── fetcher.ts
├── formatter.ts
├── game.ts
├── liveGame.ts
├── message.ts
├── tracking.ts
├── types.ts
└── useStoredState.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": "next/core-web-vitals"
}
================================================
FILE: .github/workflows/test.yml
================================================
name: test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: "14.x"
- name: Install & test
run: |
yarn install --pure-lockfile --prefer-offline
yarn test
================================================
FILE: .github/workflows/update.yml
================================================
on:
schedule:
# TODO: use new zealand time?
# Update on every 00:00 GMT+9 / WIT which means 15:00 UTC
# Also sync timezone change to update.js/TIMEZONE_OFFSET
- cron: "00 15 * * *"
jobs:
update_katla:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: "14.x"
registry-url: "https://registry.npmjs.org"
- name: install deps
run: yarn install --prefer-offline
- name: update new word
run: yarn update
env:
SECRET_DATE: ${{ secrets.SECRET_DATE }}
SECRET_WORD: ${{ secrets.SECRET_WORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
.scripts/wordle.json
.scripts/nyt.json
.scripts/analysis.js
# vercel
.vercel
# Sentry
.sentryclirc
================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
================================================
FILE: .prettierignore
================================================
build
coverage
.next
.vercel
================================================
FILE: .scripts/answers.csv
================================================
ganar,pakar,syair,ultah,danur,wisma,ruang,tajin,saksi,palsu,skrin,jeruk,vokal,belok,acian,jenuh,prabu,rahim,eceng,bocah,anyir,robek,bison,gesit,jepit,debus,beruk,porsi,uapan,sabtu,polan,parau,hukum,lawan,setan,butir,makan,rafia,virus,gemuk,comel,lobak,globe,angin,bujur,morse,jumat,undak,belit,keong,hasut,bolos,zebra,irama,rogoh,butuh,virga,waduk,strok,ideal,domba,emoji,dekor,turun,tinja,premi,nadim,kayuh,jujur,sekon,kroco,jeluk,dakwa,kodok,gipsi,volan,frasa,eksak,rompi,tulis,ranah,sadis,angka,surga,lumer,didik,ancai,cuman,yolks,sorot,aksen,garda,ulser,alang,suami,sihir,tekuk,cerai,luang,biner,seduh,hewan,jerih,iklan,caing,gimik,gumam,julur,cecak,pasar,sabar,puyuh,jujut,jiran,april,klise,imbuh,biara,darah,guyur,joker,dekil,zuhur,lerai,mambu,gusar,minum,tutor,gusur,rambu,gojek,pohon,pisau,kueri,kunci,mudah,cacah,cimol,hiena,rumor,kokok,gebuk,sipir,ceruk,duvet,versi,nuget,sepuh,bokeh,takar,andai,luber,apaan,nomor,stres,lepak,lembu,pedal,lebah,porus,cuaca,senin,alibi,berat,ngung,indra,endus,nafsu,kiara,kemon,bunyi,wajah,serah,memar,waras,tentu,lorek,bakul,mudik,folio,lalap,idiot,kimia,curam,licik,baang,cawan,dalih,uwete,tatap,satwa,licin,pasif,wesel,gagal,lotre,jambu,canda,bewok,skrub,celup,debak,resek,ampai,kumur,khiar,lahan,tipis,bihun,penad,jerat,diari,sidik,jurus,satai,kawat,cegah,besan,loket,motif,bibel,mebel,apron,penyu,kakak,luluh,kanda,jejak,radar,desau,nikah,sayup,gowok,jalan,pokok,jilid,obral,bisul,pelek,teguk,jibun,garpu,yakni,perlu,sohib,bahan,culun,sunda,bekel,singa,surau,lelet,patri,susun,datau,orang,didih,gokar,judul,napas,kibor,totol,rakyu,ralat,fatwa,tanah,yakin,batas,rakus,zombi,mahar,istri,batin,manja,bobin,lugas,tukul,serat,absen,impun,windu,kambu,konte,masif,derit,tusuk,pergi,ceker,vasal,cebol,legam,cedok,diksi,teras,kamar,rawon,bidai,andil,putus,gubuk,caima,sapun,pause,belut,kikuk,harta,cagar,mukim,punca,berak,rumus,ompok,kalke,tabur,puyer,urgen,pelik,tuduh,esais,latah,vadem,lotak,hantu,ndoro,gotik,putih,riset,daing,titis,tepuk,genre,pupus,tulus,perut,getas,kalap,cihui,delik,intim,injil,gelar,jarum,tabel,udang,jurik,saleh,gincu,gerha,nihil,jebol,adang,kikis,kebab,latif,derai,sesak,pelit,humor,avgas,bacin,semai,paras,cibuk,cerih,jihad,iring,tante,akrab,ultah,imsak,hotel,union,tikus,kotor,detik,serbu,zumba,kanal,hilal,kubis,makar,tarif,birai,trans,sisik,sukar,retro,freon,jersi,kasui,tahan,cocok,biksu,advis,ihwal,valas,jakun,mafia,cadas,daeng,setel,rasis,firma,gawai,beras,wajib,kelor,timun,kurma,rival,ludes,tajam,lodeh,wulan,krans,cabai,pelor,teteh,polis,heboh,massa,restu,orbit,makna,bongo,valid,tawon,pukul,konde,abjad,salvo,belia,opera,usang,nenek,rancu,benak,jubin,bobol,iblis,hutan,vonis,absah,honje,sinus,datar,darat,emosi,suara,culas,lusuh,cikar,duafa,jimat,nisan,larva,karya,bantu,unjuk,wahyu,coket,salju,lahap,kukuh,kring,benur,siswa,modem,acara,suatu,siaga,sawah,panas,siapa,afair,geger,kecoh,ritme,banda,gawat,warga,kuaci,karat,ovasi,lumut,cetai,lomba,kiper,lever,plang,cekah,kotak,buron,kenyi,pilah,porto,hidup,punya,lelap,sakau,prosa,kakap,lisan,pasak,rukun,semok,untuk,celus,kerja,tebak,retas,korps,ialah,jemur,cerek,terka,udara,swike,bufet,gapai,jumpa,cebur,lurik,cakar,infak,maksi,kemih,imaji,hilir,sasis,bonus,sains,jomlo,senja,bujet,ambai,ejaan,fitur,trofi,biasa,perak,bazar,pecuk,kenap,spasi,subuh,sisir,kukus,vital,lawak,umrah,tomat,katak,pivot,pahat,eboni,betis,tumit,jinak,kapak,nista,helai,tabib,mesti,semen,kribo,logat,resin,visum,uskup,garis,kakus,besit,segar,debum,impor,egois,norma,plano,makro,surya,bubur,insan,ikrar,imbal,rudal,jingu,pulen,kreol,feral,pilih,senil,turut,guyub,fondu,damar,derap,untai,terpa,intan,kawin,ciduk,spons,tifus,hindu,lampu,retak,bedak,jidur,sadar,kromo,judes,ombak,wasit,lahar,jompo,titip,skors,wafat,murid,dekap,paket,badai,opini,jijik,mesra,kapal,indah,tetua,himne,detap,mandi,tidur,urban,imlek,kerap,insaf,tanpa,tutup,oasis,botia,rebah,benua,titik,bruto,gelas,sandi,magma,avtur,tabuh,tinju,ilusi,sinar,savan,bulir,benih,wadah,jemah,tetap,ciput,warna,gagah,gravi,langu,antek,susah,inang,timur,jewer,melek,rilis,cengo,tajir,zakat,demam,perih,lotus,tokek,jawat,pekik,ivori,tensi,nyiur,teori,blitz,hiruk,dahak,agama,dogma,bebop,empuk,nasib,candu,dorbi,liwet,sebum,karst,kover,organ,kekar,tanya,cecer,kemam,siram,slang,cerun,ceria,nanas,kucur,kanan,anani,keris,kedok,balai,urung,habis,bahwa,jawil,junub,halus,keruk,gawar,mania,sumba,encok,tetas,liter,peang,kekal,minim,rubah,amboi,faali,ultah,busik,fosil,siswi,pecat,rimba,gulai,tabik,putri,jegil,jotos,cekik,hawar,wafer,limfa,acuan,cepuk,kulit,danau,yogia,pipih,pecah,takwa,rawan,kuyup,ledek,glosa,punuk,pedas,patah,puasa,orasi,nyawa,lusin,batal,tutur,rouks,buyar,objek,fokus,murka,deguk,cekok,tulat,telat,elips,butut,rindu,tiara,laksa,kroma,bidik,bezit,rugbi,decak,yatim,kunut,runut,sikat,tegel,diska,leleh,pipis,datin,burik,gawir,sanca,kenop,capai,ceter,oktaf,sorak,canai,mogok,tempe,antre,muara,razia,tanda,nyali,rahib,camat,bakda,perca,cadar,jajan,damai,fusia,tiang,kutub,ironi,atlet,ngilu,serta,iuran,payau,netra,kekat,bedil,momok,lelah,wangi,bunda,cadai,kawah,bigot,tiada,tugas,waria,liang,risau,rezim,janda,jumbo,lihai,citra,teror,drama,ceeng,ketat,joget,kurva,vakum,ujung,vista,faksi,jimbe,luwes,andal,khaki,cetus,samar,topik,pejal,telah,taoco,kefir,idiom,lalat,tatah,trafo,ihram,pakan,umami,novel,denim,syekh,ontel,niaga,kucek,fiber,beton,ulang,sause,penat,bakat,yahya,omong,empat,bioma,yonko,pejam,talak,copet,mesem,buaya,gigih,baiat,tanam,oknum,beres,jahat,subur,putra,elung,joglo,menor,buyut,sepah,bugis,fobia,kucup,flora,cacak,sonde,paksa,ranji,animo,wakaf,aktif,semut,bisai,wagyu,besut,koran,comek,admin,siang,merah,rutin,kepak,mantu,gurau,tuyul,wajan,gadis,tekor,cogan,selak,kameo,satin,gebos,sipil,jerit,rokok,seeng,cemas,tewas,susuk,stepa,sedih,boleh,tubir,koala,sibuk,ronde,menit,buani,curai,bodoh,saraf,senar,cerna,tunda,bambu,kinan,iftar,emisi,jenis,kucir,belah,rumit,embun,steno,pompa,taala,bivak,cadel,plong,kerak,siger,hirup,madya,upeti,celah,bagai,tubin,sling,wajik,sosor,kalis,tempa,nyata,cubit,unyil,bulai,rayap,kawal,jelek,gatal,busai,totok,jaksa,angan,novum,setop,tenis,cebok,alaan,taksi,letih,meses,iseng,barai,halwa,bakau,tamat,gempa,behel,kawuk,tatak,dawai,klien,sendu,dahan,tafia,sigma,pines,ajaib,silau,cekal,puisi,nakal,kelus,kawak,selir,gamis,skala,kuasa,janin,ceguk,buket,kepul,panji,jreng,bajik,payah,moral,bleng,becak,kawan,fasad,hujat,tesis,viral,mesiu,merek,sirna,colek,kidal,sahur,lazim,kekau,entok,suaka,panda,cekup,pedis,fardu,kedam,derus,vinil,arsip,namun,nasal,gajih,dower,jodoh,tolan,jawab,cling,beban,cukai,desis,sawer,sayat,oktet,enzim,intip,lepas,paraf,jatuh,warta,intro,setia,ultah,piano,sikap,sanda,pasir,fakta,ukhti,punah,lukis,binar,tiris,terus,entri,datum,sipit,pasal,botol,horas,envoi,pesek,felon,buruh,beguk,mawar,vespa,ujian,salat,rumah,kumuh,pulau,dewan,oliva,usung,nyala,jajah,pedih,onsen,makam,korve,klaim,ricuh,koplo,afiks,tikar,seken,sinis,urine,abaya,abate,bayar,dasar,fasia,gaduh,dassi,erosi,elusi,fatal,fiksi,fungi,demen,encot,gacor,tambi,ruing,awang,medio,murni,betet,keram,laten,sekat,kader,sulam,ligat,tenda,gugur,atase,medik,rusak,disel,kupat,papar,human,japan,ekses,mohon,rebut,kurau,stasi,graha,jamil,sebut,skrip,muram,becik,gerak,makin,bunuh,areal,kirab,untun,kafir,kelab,resan,harga,tegur,kubur,foton,salam,cukur,akmal,asing,nambi,ampar,pakal,figur,pusar,syura,tegak,islam,ngeri,tunik,almon,kecak,asasi,omzet,nazar,bekas,fadil,eksis,sabah,badui,jetis,tapis,kedua,colok,kania,telak,seisi,curah,umbar,masuk,grasi,kopra,fasis,jajal,geber,sisil,detak,lebak,jambe,salut,wukuf,tambo,gajah,cicil,juang,unsur,paruh,lalai,eyang,dekat,kayak,ramen,duduk,inses,alpin,bulus,palak,karel,amino,binal,seruk,angit,kejut,kupas,kuang,duhai,jamur,pihak,kudus,kasia,keluh,pasti,solid,tujuh,keran,rawat,ampel,jatah,kodak,badik,hoaks,crash,pegan,lapar,puspa,yusuf,sabda,natal,afdal,kamus,ranau,ishak,rekap,gabah,metro,gulir,bajak,seksi,rusuk,demak,sukuk,dendi,diare,eweai,kuria,cilik,studi,hadis,sidat,basis,ilham,pikat,loker,jalal,tekat,kipas,sejuk,lapuk,talun,rakit,ketua,bayur,wajar,papan,torsi,hafiz,bacok,sulit,bumbu,roker,jegog,china,acang,tanak,level,randi,donor,talam,ritel,subam,kenek,ganda,curat,gosip,petuk,legit,titan,entah,solok,kabir,pakai,jahit,bahar,bugar,griya,manah,fjord,bulan,buluh,isian,lapis,final,amsal,barak,cacar,induk,gatra,anime,reksa,fulan,tonga,infus,ahang,angel,angsa,cukup,redah,bagan,teuku,butik,nusuk,badau,pagar,gudeg,lemon,kalau,biola,ranti,sowan,lemas,kesah,sakar,utama,copot,pakis,sabuk,abung,zalim,lahab,suwir,tebal,maman,dayak,piton,musik,jasad,bisik,manta,pucuk,karim,perah,asesi,etana,aroma,imbas,lebur,dumbo,dunia,pamit,bedah,gamat,adobe,honor,sikka,bagus,rapuh,walau,tilas,linen,cicit,adika,bakam,tanur,sigap,baret,batuk,sanad,sudah,pulut,serba,penda,apnea,kusam,rabat,patil,lunak,saran,tania,oncom,kelik,ultah,pikir,ajoli,hanif,optik,kriya,pulih,dulur,bahau,lolak,tulip,nipis,gobak,magis,cemar,mitos,aswad,kasur,porno,medan,silam,kumis,utang,obeng,abang,taksu,pinta,pepah,mandu,momen,wujud,surut,coban,cempe,pamah,macet,orion,ludah,dalem,polip,sirah,fakir,tupai,rekor,bahas,madam,setek,ribut,ampat,sudan,cikal,hisab,kicau,komik,semak,viola,kedap,sogan,tukas,tahap,kekeh,cewek,dadar,kamis,yuris,kisam,manga,gardu,lupus,tiner,pandu,feses,audit,tahir,pacar,mepet,gegas,abrar,depot,jamak,kasir,antik,kenya,intel,strip,saham,silat,nalar,zahid,rapel,count,unduh,lezat,arang,sesat,lawar,ambar,ahsan,bawah,etnik,ember,bilah,kurun,kesan,mayam,sereh,ipung,tekun,serut,sebab,kerek,finis,nekat,ander,bajul,gokil
================================================
FILE: .scripts/update.js
================================================
const path = require("path");
const fs = require("fs/promises");
const { Octokit } = require("@octokit/rest");
const answerPath = path.join(__dirname, "answers.csv");
const wordsPath = path.join(__dirname, "whitelist.csv");
// sync with update.yml
const TIMEZONE_OFFSET = 9;
const readCsv = (filePath) =>
fs.readFile(filePath, "utf-8").then((text) =>
text
.split(",")
.map((s) => s.trim())
.filter(Boolean)
);
async function insertWord(answers, answer) {
if (process.env.GITHUB_ACTIONS) {
// GH actions
await writeCommit(answers.concat(answer).join(",") + "\n");
} else {
// dev testing
await fs.writeFile(answerPath, answers.concat(answer).join(","), "utf-8");
}
}
async function writeCommit(data) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("Missing GitHub token env in `GITHUB_TOKEN`");
}
const FileInfo = {
owner: "pveyes",
repo: "katla",
path: ".scripts/answers.csv",
sha: "main",
};
const octokit = new Octokit({
baseUrl: "https://api.github.com",
auth: `token ${token}`,
});
const response = await octokit.repos.getContent(FileInfo);
const { sha } = response.data;
octokit.repos.createOrUpdateFileContents({
...FileInfo,
message: "Insert new answer",
content: Buffer.from(data).toString("base64"),
sha,
branch: "main",
});
}
function getMidnightDate() {
const now = new Date();
now.setHours(now.getHours() + TIMEZONE_OFFSET);
const deltaToMidnightMinutes = 60 - now.getMinutes();
now.setMinutes(now.getMinutes() + deltaToMidnightMinutes);
return now;
}
async function main() {
const [usedWords, allWords] = await Promise.all([
readCsv(answerPath),
readCsv(wordsPath),
]);
const validWords = allWords.filter((word) => !usedWords.includes(word));
// use let to allow secret words
let word = validWords[Math.floor(Math.random() * validWords.length)];
const secretDate = process.env.SECRET_DATE;
const secretWord = process.env.SECRET_WORD;
if (secretDate && secretWord) {
const date = getMidnightDate();
const [mm, dd] = secretDate.split("-").map(Number);
if (date.getDate() == dd && date.getMonth() + 1 === mm) {
word = secretWord;
}
}
await insertWord(usedWords, word);
console.log("New word inserted", word);
}
main().catch((error) => {
console.error("Failed", error);
process.exit(1);
});
================================================
FILE: .scripts/whitelist.csv
================================================
abadi,abang,abate,abawi,abaya,abbas,abjad,abrar,absah,absen,abung,abuya,acang,acara,acian,acuan,adang,adika,admin,adnan,adobe,adong,aduan,adven,advis,afair,afdal,afiks,agama,agami,agogo,agung,ahang,ahsan,ajaib,ajang,ajoli,akbar,akhir,akmal,akrab,aksen,akses,akson,aktif,aktor,alaan,alami,alang,alarm,album,alias,alibi,alien,allah,almon,alpin,altar,amang,ambah,ambai,ambar,amber,ambil,ambin,amboi,ambon,amina,amino,amorf,ampai,ampar,ampas,ampat,ampel,ampuh,ampun,amsal,anang,anani,ancai,ancam,ancol,andai,andal,andan,andap,andar,ander,andil,andir,aneka,angan,angel,angga,angin,angit,angka,angke,angsa,anime,animo,anjar,ansar,antar,antek,antik,anton,antre,anyam,anyar,anyir,aorta,apaan,apnea,apoge,april,apron,apung,arang,areal,arena,argon,ariel,aries,aroma,arsip,artis,arung,arwah,asasi,asesi,asian,asing,askar,aspek,aswad,asyik,atase,atlas,atlet,atung,audio,audit,aulia,aurat,autis,avgas,avtur,awang,aytek,baang,babad,babak,baban,babar,babat,bacan,bacin,bacok,badai,badak,badal,badan,badar,badau,badik,badui,badur,badut,bagai,bagan,bagas,bagus,bahan,bahar,bahas,bahau,bahri,bahwa,baiat,bajaj,bajak,bajik,bajir,bajul,bakal,bakam,bakar,bakat,bakau,bakda,bakmi,bakso,bakti,bakul,balad,balah,balai,balak,balap,balas,balen,balet,balig,balik,balok,bambu,banda,bando,banga,banta,bantu,banua,banyo,banyu,bapak,barai,barak,baran,baras,barat,barel,baret,barik,baris,barok,baros,basah,basar,basin,basir,basis,basmi,batak,batal,batas,batik,batin,batok,batuk,batur,baung,bawah,bayak,bayam,bayar,bayur,bazar,bebal,beban,bebas,bebek,beber,bebop,becak,becek,becik,becus,bedah,bedak,bedil,beduk,begah,begal,beguk,behel,bejat,bekal,bekam,bekas,bekel,beken,belah,belas,belia,belit,belok,belom,beluk,belum,belut,benak,benar,benci,benda,benih,benua,benur,berak,beras,berat,berau,beres,beruk,besan,besar,beser,besit,besok,besuk,besut,betah,betet,betis,beton,betul,bewok,bezit,biang,biara,biasa,biaya,bibel,bibir,bibit,bidah,bidai,bidak,bidan,bidar,bidik,bigot,bihun,bijak,bijih,bikin,biksu,bilah,bilal,bilas,bilik,binal,binar,biner,binti,biola,bioma,biota,birai,bisai,bisik,bison,bisul,bivak,blang,bleng,blitz,blong,blues,bobin,bobol,bobot,bocah,bocor,bodoh,bogem,bogor,bokap,bokeh,bokor,boleh,bolos,bonar,bongo,bonus,borok,boros,bosan,botak,botia,botol,brata,bruto,buana,buang,buani,buaya,bubar,bubuk,bubur,budak,buduk,bufet,bugar,bugis,bujet,bujuk,bujur,bukan,buket,bukit,bukti,bulai,bulak,bulan,bulat,bulir,buluh,bulus,bumbu,bunda,bunga,buntu,bunuh,bunut,bunyi,buram,burik,buron,bursa,buruh,buruk,busai,busik,busuk,busur,butik,butir,buton,butuh,butut,buyar,buyut,cabai,cabul,cabut,cacah,cacak,cacar,cacat,cadai,cadar,cadas,cadel,cagar,caima,caing,cakap,cakar,cakep,cakra,cakup,calon,camat,canai,canda,candi,candu,capai,capek,caren,carik,carok,carut,catat,catur,cawan,ceban,cebok,cebol,cebur,cecak,cecer,cedok,ceeng,cegah,cegat,ceguk,cekah,cekal,ceker,cekik,cekok,cekup,cekut,celah,celik,celuk,celup,celus,cemar,cemas,cempe,cengi,cengo,cepat,cepek,ceper,cepol,cepuk,cerah,cerai,cerek,ceria,cerih,cerna,ceruk,cerun,cetai,cetak,ceter,cetus,cewek,china,cibuk,cicil,cicit,ciduk,cihui,cikal,cikar,cilik,cilok,cimol,cinde,cinta,cipta,ciput,citra,clear,cling,coban,cocok,cogan,coket,colek,colok,comek,comel,copet,copot,corak,coret,count,cowok,crash,cuaca,cubit,cucun,cucup,cukai,cukup,cukur,culas,culun,cuman,curah,curai,curam,curat,dabal,dabol,dadap,dadar,daeng,dahak,dahan,daing,dairi,dakar,dakon,daksa,dakwa,dalam,dalem,dalih,dalil,damai,damar,danau,danda,dandi,dange,dansa,danur,danus,dapat,dapur,darah,darat,darma,darun,dasar,dassi,datar,datau,datin,datuk,datum,dawai,dawan,dawet,dayah,dayak,debak,debat,debit,debum,debus,debut,decak,dedek,degan,degen,deguk,dekan,dekap,dekat,dekil,dekor,delas,delik,delta,demak,demam,demen,denah,denda,dendi,denim,denok,depak,depan,depok,depot,derai,deran,derap,deras,derbi,derek,derit,derma,derus,desak,desau,desil,desis,detak,detap,detik,deuce,dewan,diare,diari,didih,didik,digit,dikit,diksi,dikte,diler,dinar,dinas,dinda,dipol,disel,diska,disko,diuji,doang,dobel,dodot,dogma,dolar,dolok,domba,dompu,donat,donor,dorbi,dorna,dosen,dosis,dower,doyan,drama,duafa,dubur,duduk,duhai,dukuh,dukun,dulur,dumai,dumbo,dunia,dupak,dusta,dusun,duvet,duwet,eboni,eceng,edisi,egois,ejaan,eksak,ekses,eksil,eksis,elang,elips,elite,elung,elusi,email,emang,emban,embat,ember,embun,emisi,emoji,emosi,empal,empat,empot,empuk,encer,encik,encim,encok,encot,endus,eneng,engku,entah,entit,entok,entri,envoi,enzim,eropa,erosi,esais,ester,etana,etape,etika,etnik,etnis,eweai,eyang,faali,fadil,fajar,fakir,faksi,fakta,falah,falaj,fardu,fasad,fasia,fasih,fasis,fatah,fatal,fatir,fatwa,fault,fauna,felon,feral,feses,fiber,figur,fikih,fiksi,filum,final,finis,firma,fisik,fitri,fitur,fjord,flora,fluks,fobia,fokus,folat,folio,fondu,forte,forum,fosil,foton,foyer,frasa,freon,front,fulan,fulus,fungi,fusia,fyord,gabah,gabus,gacor,gadai,gadis,gaduh,gafur,gagah,gagak,gagal,gagap,gagas,gahar,gajah,gajih,galah,galak,galat,galau,galih,galon,galuh,galur,gamat,gamet,gamis,ganar,ganas,ganda,ganja,ganti,gapai,gaple,garam,garap,garda,gardu,garis,garpu,garut,gatal,gatot,gatra,gaung,gawai,gawan,gawar,gawat,gawir,gayuh,geber,gebos,gebuk,gedor,gegap,gegas,geger,gelak,gelap,gelar,gelas,gelut,gemar,gempa,gemuk,genap,genom,genre,genta,genuk,genus,gerah,gerai,gerak,geram,gerha,geser,gesit,getah,getar,getas,getek,getok,getol,gigih,gigit,gimik,gincu,gipsi,gitar,globe,glosa,gobak,godok,gojek,gokar,gokil,golok,gores,gorok,gorup,gosip,gotik,gowes,gowok,goyah,graha,grasi,gravi,griya,guano,gubuk,gudeg,gugat,gugup,gugur,gugus,gulai,gulam,gulat,gulir,gulma,gumam,gunci,gurah,gurau,gurih,gurit,guruh,gurun,gurur,gusar,gusti,gusur,guyon,guyub,guyur,habib,habil,habis,hadap,hadas,hadir,hadis,hafal,hafiz,hajar,hajat,hakim,halal,halau,halim,halte,halus,halwa,hamba,hamil,hampa,hanif,hantu,hanya,hapus,haram,harap,harga,haris,harta,harum,harun,harus,hasan,hasil,hasta,hasut,hatta,hawar,hayat,hebat,heboh,heiho,helai,hemat,hendi,henry,henti,heran,hewan,hibah,hibur,hidro,hidup,hiena,hijab,hijau,hilal,hilir,himne,hindu,hiruk,hirup,hisab,hitam,hoaks,honje,honor,horas,horor,hotel,hujan,hujat,hukum,human,humor,humus,huruf,hutan,ialah,iblis,ideal,idiom,idiot,idola,idris,iftar,ihram,ihsan,ihwal,iklan,iklim,ikram,ikrar,ilafi,ilahi,ilham,ilusi,ilyas,imago,imaji,imani,imbal,imbas,imbau,imbuh,imlek,impor,impun,imsak,inang,indah,india,indie,indik,indra,induk,infak,infus,ingat,ingin,ingus,injak,injil,input,insaf,insan,inses,intan,intel,intim,intip,intro,ipung,irama,irian,iring,ironi,isbat,iseng,ishak,isian,islah,islam,islan,istri,iuran,ivori,jabal,jabat,jabir,jagat,jagur,jahat,jahil,jahit,jajah,jajak,jajal,jajan,jajar,jaket,jaksa,jakun,jalak,jalal,jalan,jalar,jalen,jalil,jalin,jalur,jamak,jamal,jambe,jambi,jambu,jamil,jamin,jamur,janah,janda,janin,janji,janur,japan,jarak,jarum,jasad,jatah,jatuh,jauza,jawab,jawat,jawil,jebak,jebol,jegal,jegil,jegog,jejak,jelas,jelek,jeluk,jemah,jemur,jenis,jenuh,jepit,jeram,jerat,jerih,jerit,jersi,jeruk,jetis,jewer,jibun,jidur,jihad,jijik,jilid,jimak,jimat,jimbe,jinak,jingu,jiran,jodoh,joget,joglo,johan,johar,joker,jomlo,jompo,jorok,jotos,jreng,juang,juara,jubah,jubin,judes,judul,juita,jujur,jujut,julur,jumat,jumbo,jumpa,junta,junub,jurai,jurik,jurus,kabar,kabau,kabel,kabil,kabin,kabir,kabul,kabur,kabut,kacau,kacer,kacuk,kadal,kadar,kader,kadir,kafir,kaget,kagum,kahar,kaili,kakak,kakao,kakap,kakek,kakus,kalah,kalak,kalam,kalap,kalau,kalbu,kaldu,kalem,kalih,kalis,kalke,kalor,kamal,kamar,kamba,kambu,kameo,kamil,kamis,kamus,kanal,kanan,kanda,kania,kanji,kanya,kapak,kapal,kapan,kapas,kapat,kapok,kapuk,kapur,karah,karam,karat,karel,karet,kargo,karib,karim,karma,karst,kartu,karya,kasap,kasar,kasep,kaset,kasia,kasih,kasim,kasir,kasta,kasui,kasur,kasus,katai,katak,katib,katik,katun,kawah,kawak,kawal,kawan,kawat,kawin,kawuk,kayak,kayuh,kebab,kebal,kebun,kebut,kecak,kecam,kecap,kecil,kecoh,kedah,kedai,kedam,kedap,keder,kedok,kedua,kefir,kejam,kejer,kejut,kekal,kekar,kekat,kekau,kekeh,kelab,kelak,kelam,kelar,kelas,kelih,kelik,kelok,kelor,kelua,keluh,kelus,kemah,kemal,kemam,kemas,kemih,kemis,kemit,kemon,kenal,kenan,kenap,kenek,kenop,kento,kenya,kenyi,keong,kepak,kepal,kepuh,kepul,kerah,kerak,keram,keran,kerap,keras,kerau,kerek,keren,keris,kerja,kerok,keruh,keruk,kesah,kesal,kesan,kesek,kesel,keset,ketan,ketat,ketik,ketok,ketua,ketuk,ketut,khair,khaki,khiar,kiara,kibor,kicau,kidal,kidul,kikil,kikis,kikuk,kilah,kilas,kilat,kilau,kimia,kinan,kipas,kiper,kirab,kiras,kirim,kisah,kisam,kista,kitab,kitir,klaim,klien,klise,kluet,koala,kobra,kocak,kocek,kocok,kodak,kodok,kokoh,kokok,kolak,kolam,kolom,kolot,komen,komet,komik,kompi,konco,konde,konga,konon,konte,konus,koper,koplo,kopra,koran,korea,korek,korps,korve,kosen,kotak,kotor,kover,koyak,krans,kreol,kribo,krida,kring,kriya,kroco,kroma,kromo,kuaci,kuala,kuang,kuasa,kubah,kubik,kubis,kubur,kubus,kucek,kucir,kucup,kucur,kudus,kueri,kukis,kukuh,kukus,kulit,kulon,kulup,kumai,kuman,kumat,kumis,kumuh,kumur,kunci,kunta,kunut,kuota,kupas,kupat,kupon,kurau,kurdi,kuria,kurir,kurma,kursi,kurun,kurus,kurva,kusam,kusir,kusta,kutai,kutip,kutub,kuyup,label,labil,labuh,lafal,lafaz,lahab,lahad,lahan,lahap,lahar,lahat,lahir,lajur,lakon,laksa,lalai,lalap,lalat,lalau,lamak,laman,lamar,lampu,lamun,langi,langu,lapak,lapar,lapis,lapor,lapuk,laras,laris,larut,larva,laser,lasik,lasut,latah,latar,laten,latif,latih,latin,lawah,lawak,lawan,lawar,lawas,layak,layar,layer,lazim,lebah,lebak,lebam,lebar,lebat,lebih,lebur,lecet,ledak,ledek,ledes,legal,legam,legit,legok,leher,lekas,lekat,lekir,lekuk,lelah,lelap,leleh,lelet,lemah,lemak,leman,lemas,lembu,lemon,lensa,lepak,lepas,lepuh,lerai,lerak,letak,letih,level,lever,lewat,lezat,liang,libas,libra,libur,licik,licin,lidah,ligat,lihai,lihat,lilin,lilit,limas,limau,limfa,limit,linda,linen,lines,liong,lipai,lipat,lipid,lirih,lirik,liris,lisan,liter,liwan,liwet,lobak,lodeh,logam,logat,logis,lokal,loker,loket,lokus,lolak,lolos,lomba,longo,lorek,lotak,lotre,lotus,loyal,luang,luber,lubuk,ludah,ludes,lugas,luhur,lukas,lukis,luluh,luluk,lulus,lulut,lumer,lumut,lunak,lunar,lunas,lupus,luput,lurah,lurik,luruh,lurus,lusin,lusuh,lutut,luwes,mabuk,macam,macan,macet,madam,madya,mafia,magis,magma,mahal,mahar,mahdi,mahir,majas,makam,makan,makar,makin,makna,makro,maksi,malah,malam,malas,malik,mamah,mamak,maman,mamat,mambo,mambu,mampu,manah,manda,mandi,mandu,manga,mania,manik,manis,manja,manna,manta,mantu,manuk,mapan,mapor,marah,marak,maras,maret,marga,maria,maros,marut,masak,masif,masih,mason,massa,masuk,matan,matik,matra,maung,mawar,mawas,mayam,mayat,mayor,mayur,mebel,medan,media,medik,medio,medis,megah,megan,mekar,melek,melon,memar,menit,menor,menua,menur,meong,mepet,merah,merak,mercu,merdu,merek,merta,mesem,meses,mesin,mesiu,meski,mesra,mesti,mesum,metal,meter,metro,mewah,mezzo,mikro,milad,milik,milir,mimpi,minat,minda,minim,minor,minta,minum,minus,mirah,mirip,miris,misal,misil,mitos,mitra,mizan,mobil,modal,model,modem,modis,modul,modus,mogok,mohon,molek,molen,molor,momen,momok,moral,moron,morse,motel,motif,motor,muara,mudah,mudik,mufti,mujur,mukim,mulai,mulas,mulia,muluk,mulus,mulut,murad,murah,murai,mural,muram,murid,murka,murni,musik,musim,musti,musuh,nabil,nadim,nadir,nafsu,nahak,nahas,najis,nakal,nalar,nambi,namun,nanah,nanas,nanti,napas,nasab,nasal,nasib,natal,natar,nazar,nazir,ndoro,nduga,necis,nekat,nenek,netra,ngada,ngawi,ngeri,ngilu,ngoyo,ngung,niaga,nihil,nikah,nikel,nilai,nilam,nilon,nimfa,ninik,ninja,nipah,nipis,nisab,nisan,nista,nomor,norma,notes,novel,novum,nuget,nujum,nusuk,nuzul,nyala,nyali,nyata,nyawa,nyepi,nyeri,nyiur,oasis,obeng,objek,oblak,obral,obras,oknum,oktaf,oktet,oleng,oliva,ombak,omega,omong,ompok,omzet,oncom,onsen,ontel,opera,opini,opsen,optik,orang,orasi,orbit,order,organ,orion,osean,ovasi,oyong,pacar,pacet,pacul,padak,padam,padan,padar,padas,padat,padma,pagar,pagon,paham,pahat,pahit,paing,pajak,pakai,pakal,pakan,pakar,pakem,paket,pakis,paksa,paksi,pakta,palak,palar,palas,palem,palet,palit,palka,palsu,pamah,paman,pamer,pamit,panah,panai,panas,panci,panda,pandu,panel,panen,panik,panji,panti,papah,papan,papar,papua,paraf,parah,parak,paras,parau,paris,parit,parsi,paruh,parut,pasai,pasak,pasal,pasan,pasar,paser,pasif,pasir,pasok,pasta,pasti,patah,patar,paten,patih,patil,patin,patok,patri,patuh,patuk,patut,pause,pawai,payah,payau,payet,peang,pecah,pecat,pecel,pecuk,pedal,pedas,pedih,pedis,pegal,pegan,pegon,pejal,pejam,pekan,pekat,pekik,pelak,pelan,pelat,pelek,pelet,pelik,pelit,pelor,penad,penat,penda,penuh,penyu,pepah,pepes,perah,perak,peran,peras,perca,pergi,perih,perlu,peron,perut,pesan,pesat,pesek,pesta,pesut,petak,petes,petik,petir,petis,petra,petuk,piala,piano,piatu,pidie,pihak,pijar,pijat,pikap,pikat,piket,pikir,pikul,pikun,pilah,pilar,pilek,pilih,pilot,pilus,pines,pinta,pintu,pinus,pipih,pipil,pipis,pipit,pisah,pisau,pitak,pitam,piton,pivot,plang,plano,plato,plaza,pleno,plong,pluto,pohon,poise,pojok,poker,poket,pokok,polan,polar,polio,polip,polis,polos,pompa,ponco,popok,porno,poros,porsi,porto,porus,prabu,praja,premi,prima,promo,prosa,puasa,pucat,pucuk,pudar,pudel,puguh,puing,puisi,pujut,pukul,pulas,pulau,pulen,pulih,pulsa,puluh,pulut,punah,punai,punca,pundi,punia,punti,punuk,punya,pupuk,pupus,puput,purba,purut,purwa,pusar,pusat,puspa,putar,putih,putra,putri,putus,putut,puyer,puyuh,qanun,rabat,rabun,racik,racun,radar,raden,radif,radin,radio,rafia,ragam,rahib,rahim,rajab,rajin,rajut,raket,rakit,rakus,rakyu,ralat,ramah,ramai,rambu,ramen,ranah,ranau,rancu,randi,randu,rangu,ranji,ranti,rapak,rapat,rapel,rapor,rapuh,raras,rasau,rasio,rasis,rasul,ratna,ratus,raung,rawan,rawas,rawat,rawit,rawon,rayap,rayon,razia,rebab,rebah,rebus,rebut,receh,redah,redam,redup,regan,regas,rehab,rehal,rehat,rejan,rekam,rekan,rekap,rekor,reksa,remeh,remuk,repot,resah,resan,resek,resep,reses,reset,resik,resin,resmi,resor,resto,restu,retak,retas,retro,reuni,rewel,reyot,rezim,riang,ribet,ribut,ricuh,rijal,rilis,rimba,rindu,risau,riset,ritel,ritme,ritus,rival,robek,roboh,robot,rogoh,roker,roket,rokok,roman,rompi,ronda,ronde,rondo,rotan,rouge,rouks,royal,royan,ruang,rubah,rubik,rubin,rudal,rugbi,ruing,rujak,rujuk,rukun,rumah,rumit,rumor,rumpi,rumus,rungu,runut,rural,rusak,rusuk,rutin,ruwah,ruwet,sabah,saban,sabar,sabda,sabet,sabit,sabtu,sabuk,sabun,sabut,sadap,sadar,sadis,safar,saham,sahih,sahur,sahut,saing,sains,sajak,sakar,sakat,sakau,sakit,saksi,sakti,salaf,salah,salak,salam,salat,saldo,saleh,salem,salep,salib,salim,salin,salip,salju,salon,salto,salur,salut,salvo,samad,saman,samar,samas,samba,sambi,samin,sampo,samsu,sanad,sanak,sanca,sanda,sandi,sanga,santa,santo,sapun,saraf,saran,sarat,saren,sarmi,sasis,satai,satin,satwa,saung,sauri,sause,savan,sawah,sawai,sawer,sawit,sayap,sayat,sayid,sayup,sayur,sebab,sebal,sebar,sebel,sebum,sebut,sedan,sedap,sedia,sedih,sedot,seduh,seeng,segan,segar,segel,sehat,seisi,sejak,sejuk,sekak,sekam,sekar,sekat,seken,sekip,sekon,seksi,sekte,selai,selak,selam,selar,selat,selip,selir,selok,seluk,semai,semak,semen,semok,semua,semur,semut,senam,senar,senat,sendi,sendu,senil,senin,senja,sepah,sepak,sepat,sepen,sepuh,serah,serai,seram,serap,serat,serba,serbu,sereh,serep,seret,serge,serit,serta,seruk,serut,serve,sesak,sesar,sesat,setan,setek,setel,setia,setir,setop,setor,setra,setup,sewot,siaga,siang,siapa,siber,sibuk,sidat,sidik,sifat,sigap,siger,sigma,sihir,sikap,sikat,sikka,siksa,silam,silat,silau,silet,silih,simak,sinar,singa,sinis,sinka,sinom,sinus,sipil,sipir,sipit,siput,sirah,siram,sirih,sirip,sirna,sirop,sisik,sisil,sisir,siswa,siswi,situs,skala,skema,skors,skrin,skrip,skrub,skuad,slang,sling,sobat,sobek,sodok,sodor,sogan,sohar,sohib,soket,solar,solid,solok,sonar,sonde,sopan,sopir,sorak,sorot,sosok,sosor,sowan,spasi,spion,spons,spora,sport,start,stasi,stela,steno,stepa,stres,strip,strok,studi,suaka,suami,suang,suara,suari,suatu,subak,subam,subuh,subur,sudah,sudan,sudut,sugar,sugih,suite,sujud,sukar,sukma,sukuk,sukun,sulam,sulap,sulbi,sulit,sumah,sumba,sumbu,sumur,sunah,sunan,sunat,sunda,sunyi,super,supit,surah,suram,surat,surau,surga,suruh,surut,surya,susah,susuk,susul,susun,susut,sutan,sutra,suwir,swike,syair,syekh,syiar,syura,taala,tabah,tabat,tabel,tabib,tabik,tabir,tabuh,tabur,tadah,tafia,tagih,tahan,tahap,tahar,tahir,tahun,tajak,tajam,tajin,tajir,tajuk,takar,taksi,taksu,takut,takwa,talak,talam,talas,talun,talut,tamam,taman,tamat,tambi,tambo,tamil,tampo,tanah,tanak,tanam,tanda,tandu,tango,tania,tanpa,tante,tanur,tanya,taoco,taoge,tapai,tapak,tapal,tapin,tapis,taraf,tarif,tarik,tarot,taruh,tasik,tatah,tatak,tatal,tatap,tawaf,tawar,tawon,tawur,tayub,tebak,tebal,tebar,tebas,tebet,tebus,teduh,tegak,tegal,tegar,tegas,tegel,teguh,teguk,tegur,tekad,tekan,tekat,tekel,teken,tekor,tekuk,tekun,telah,telak,telan,telap,telat,telor,teluk,telur,teman,tempa,tempe,tempo,tenar,tenda,tenis,tenor,tensi,tentu,tenun,teori,tepat,tepis,tepok,tepuk,tepus,teras,terik,terka,teror,terpa,terus,tesis,tesla,tetap,tetas,teteh,tetes,tetra,tetua,teuku,tewas,tiada,tiang,tiara,tiban,tidak,tidur,tifus,tikar,tiket,tikus,tilas,timah,timpa,timun,timur,tiner,tinja,tinju,tinta,tipar,tipis,tirai,tiram,tiran,tiris,tirta,tirus,titan,titel,titen,titik,titip,titis,tiwul,tobat,togok,tokek,token,tokoh,tolak,tolan,tolok,toman,tomas,tomat,tonga,topan,topik,torsi,total,totok,totol,towel,trafo,trans,trofi,tuang,tuban,tubin,tubir,tubuh,tuduh,tugas,tuhan,tujuh,tukar,tukas,tukik,tukul,tular,tulat,tulen,tulip,tulis,tulus,tumis,tumit,tumor,tunai,tunas,tunda,tunik,tupai,turap,turis,turki,turun,turut,tusuk,tutor,tutul,tutup,tutur,tutut,tuyul,uapan,ubung,udang,udara,udeng,ujang,ujian,ujung,ukhti,ulama,ulang,ulkus,ulser,ultah,ulung,umami,umara,umbar,umbul,umpan,umpet,umrah,undak,unduh,undur,union,unjuk,unsur,untai,untuk,untun,unyil,upaya,upeti,urban,urgen,urine,urung,usaha,usang,using,uskup,ustaz,usung,utama,utang,utara,uwete,vadem,vakum,valas,valid,vasal,vegan,venus,versi,vespa,veste,video,vinil,viola,viral,virga,virgo,virus,vista,visum,vital,vodka,vokal,volan,vonis,wabah,wadah,wadak,waduh,waduk,wafat,wafer,wagyu,wahai,wahid,wahyu,wajah,wajan,wajar,wajib,wajik,wakaf,wakil,waktu,walau,walet,wanam,wanda,wangi,waras,warga,waria,waris,warna,warta,wasit,watak,water,welas,wesel,wetan,weton,wilis,windu,wisma,witir,wiwik,wujud,wukuf,wulan,wushu,xenia,yacht,yahya,yaitu,yakin,yakni,yakub,yasti,yatim,yesus,yogia,yolks,yonif,yonko,yudas,yunda,yunus,yuris,yusuf,zabur,zahid,zakat,zalim,zaman,zapin,zebra,zikir,zirah,zombi,zuhud,zuhur,zumba
================================================
FILE: .vscode/settings.json
================================================
{
"prettier.singleQuote": false,
"prettier.arrowParens": "always"
}
================================================
FILE: README.md
================================================
# Katla
Permainan kata harian. ~~Imitasi~~ Terinspirasi dari [Wordle](https://www.powerlanguage.co.uk/wordle/)
[](https://vercel.com?utm_source=katla&utm_campaign=oss)
================================================
FILE: components/Alert.tsx
================================================
import toast, { Toaster } from "react-hot-toast";
export default function Alert() {
return (
);
}
Alert.options = {
duration: 750,
className:
"bg-gray-900 text-white dark:bg-white dark:text-black text-center font-semibold py-2 px-3 rounded-sm",
};
interface AlertOptions {
duration?: number;
cb?: () => void;
id: string;
}
Alert.show = (message: string, options: AlertOptions) => {
const duration = options.duration || Alert.options.duration;
let formatted: any = message;
if (message.includes("\n")) {
const lines = message.split("\n");
formatted = (
{lines.map((line, i) => {
if (i === lines.length) {
return line;
}
return (
<>
{line}
>
);
})}
);
}
toast(formatted, {
id: options.id,
duration,
});
if (options.cb) {
setTimeout(() => {
options.cb();
}, duration);
}
};
================================================
FILE: components/App.tsx
================================================
import { useEffect, useRef, useState } from "react";
import Board from "./Board";
import Keyboard from "./Keyboard";
import { GameStats } from "../utils/types";
import { decode } from "../utils/codec";
import { handleGameComplete, getFailureMessage } from "../utils/message";
import {
checkHardModeAnswer,
getAnswerStates,
verifyStreak,
} from "../utils/game";
import { Game } from "../utils/types";
import { trackEvent } from "../utils/tracking";
import Alert from "./Alert";
interface Props {
game: Game;
stats: GameStats;
words: string[];
setStats: (stats: GameStats) => void;
showStats: () => void;
// optional event handlers for different game modes
onSubmit?: (game: Game, userAnswer: string) => void;
onComplete?: typeof handleGameComplete;
}
export default function App(props: Props) {
const { game, stats, setStats, showStats, words } = props;
const [invalidAnswer, setInvalidAnswer] = useState(false);
const isAnimating = useRef(null);
const answer = decode(game.hash);
function handlePressChar(char: string) {
// ignore if already finished
if (game.state.answers[game.state.attempt - 1] === answer) {
return;
}
if (isAnimating.current) {
return;
}
if (!game.state.enableFreeEdit && char === "_") {
return;
}
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
if (answer.length === 5) {
if (answer.includes("_")) {
return answer.replace("_", char.toLowerCase());
}
// do nothing
return answer;
}
return answer + char.toLowerCase();
}
return answer;
}),
});
}
function handleBackspace() {
if (isAnimating.current) {
return;
}
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return answer.slice(0, -1);
}
return answer;
}),
});
}
function handleSubmit() {
if (isAnimating.current) {
return;
}
// ignore submission user already know the answer
if (
// already fail
game.state.attempt === 6 ||
// already found the answer
game.state.answers[game.state.attempt - 1] === answer
) {
return;
}
const userAnswer = game.state.answers[game.state.attempt]
.split("")
.filter((char) => char !== "_")
.join("");
if (userAnswer.length < 5) {
markInvalid();
Alert.show("Tidak cukup huruf", { id: "answer" });
return;
}
if (!words.includes(userAnswer.toLowerCase())) {
markInvalid();
Alert.show("Tidak ada dalam KBBI", { id: "answer" });
game.trackInvalidWord(userAnswer);
return;
}
if (game.state.enableHardMode && game.state.attempt > 0) {
const [isInvalid, unusedChar, leterIndex] = checkHardModeAnswer(
game.state,
answer
);
if (isInvalid) {
markInvalid();
if (leterIndex) {
Alert.show(`Huruf ke-${leterIndex} harus ${unusedChar}`, {
id: "answer",
});
} else {
Alert.show(`Huruf ${unusedChar} harus dipakai`, { id: "answer" });
}
return;
}
}
props.onSubmit?.(game, userAnswer);
setInvalidAnswer(false);
game.submitAnswer?.(userAnswer, game.state.attempt + 1);
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return userAnswer;
}
return answer;
}),
attempt: game.state.attempt + 1,
lastCompletedDate: game.state.lastCompletedDate,
});
isAnimating.current = true;
setTimeout(() => {
isAnimating.current = false;
if (answer === userAnswer) {
if (typeof game.submitAnswer === "function") {
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return userAnswer;
}
return answer;
}),
attempt: game.state.attempt + 1,
lastCompletedDate: new Date().getTime(),
});
return;
}
props.onComplete?.({
hash: game.hash,
attempt: game.state.attempt + 1,
stats: stats,
cb: showStats,
});
trackEvent("succeed", {
hash: game.hash,
attempt: game.state.attempt + 1,
});
const currentStreak = stats.currentStreak + 1;
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return userAnswer;
}
return answer;
}),
attempt: game.state.attempt + 1,
lastCompletedDate: new Date().getTime(),
});
setStats({
distribution: {
...stats.distribution,
[game.state.attempt + 1]:
stats.distribution[game.state.attempt + 1] + 1,
},
currentStreak,
maxStreak: Math.max(stats.maxStreak, currentStreak),
});
} else if (game.state.attempt === 5) {
if (typeof game.submitAnswer === "function") {
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return userAnswer;
}
return answer;
}),
attempt: game.state.attempt + 1,
lastCompletedDate: new Date().getTime(),
});
return;
}
trackEvent("failed", { hash: game.hash });
setStats({
distribution: {
...stats.distribution,
fail: stats.distribution.fail + 1,
},
currentStreak: 0,
maxStreak: stats.maxStreak,
});
const failureMessage = getFailureMessage(
stats,
getAnswerStates(game.state.answers[game.state.attempt], answer)
);
Alert.show(`${failureMessage}. Jawaban: ${answer}`, {
id: "finish",
duration: 1250,
cb: showStats,
});
}
}, 400 * 6);
}
function markInvalid() {
setInvalidAnswer(true);
setTimeout(() => {
setInvalidAnswer(false);
}, 600);
}
// auto resize board game to fit screen
useEffect(() => {
if (!game.ready) {
return;
}
function handleResize() {
const katla = document.querySelector("#katla") as HTMLDivElement;
const footerHeight =
document.querySelector("footer")?.getBoundingClientRect()?.height ?? 0;
const maxTileHeight =
window.innerHeight -
document.querySelector("#header").getBoundingClientRect().height -
document.querySelector("#keyboard").getBoundingClientRect().height -
footerHeight;
document.querySelector("#game-bar")?.getBoundingClientRect()?.height ?? 0;
const maxTileSize = Math.min(maxTileHeight, window.innerWidth);
const singleTileSize = Math.max(Math.floor((maxTileSize - 30) / 6), 62);
const tileWidth = 5 * singleTileSize + 42;
katla.style.height = maxTileSize + "px";
katla.style.width = tileWidth + "px";
}
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [game.ready]);
return (
<>
>
);
}
================================================
FILE: components/Board.tsx
================================================
import Tile from "./Tile";
import { Game, GameState } from "../utils/types";
import { decode } from "../utils/codec";
import { getAnswerStates } from "../utils/game";
import { FLIP_ANIMATION_DELAY_MS } from "../utils/constants";
interface Props {
game: Game;
invalidAnswer: boolean;
}
export default function Board(props: Props) {
const { game, invalidAnswer } = props;
const answer = decode(game.hash);
function handlePress(row: number, index: number) {
if (game.state.enableFreeEdit && row === game.state.attempt) {
game.setState({
...game.state,
answers: game.state.answers.map((answer, i) => {
if (i === game.state.attempt) {
return answer.slice(0, index) + "_" + answer.slice(index + 1);
}
return answer;
}),
});
}
}
return (
{Array(6)
.fill("")
.map((_, i) => {
let userAnswer = game.state.answers[i] ?? "";
userAnswer += " ".repeat(5 - userAnswer.length);
const answerStates = getAnswerStates(userAnswer, answer);
const lieBoxes = game.state.lieBoxes?.[i];
const isTheAnswer = answerStates.every((answer) => answer === "c");
return (
{userAnswer.split("").map((char, index) => {
let state = null;
if (i < game.state.attempt) {
state = answerStates[index];
const [forcedColumn, forcedState] = lieBoxes ?? [];
if (
// only replace the box if liar mode is enabled
game.state.enableLiarMode &&
// and the column matches
forcedColumn === index &&
// and it's not the answer
!isTheAnswer
) {
state = forcedState;
}
}
const isInvalid = invalidAnswer && i === game.state.attempt;
return (
handlePress(i, index)}
/>
);
})}
);
})}
);
}
================================================
FILE: components/Container.tsx
================================================
import { PropsWithChildren } from "react";
export default function Container(props: PropsWithChildren<{}>) {
return (
{props.children}
);
}
================================================
FILE: components/EmojiRain.tsx
================================================
import { ComponentRef, useEffect, useRef, useState } from "react";
const CUSTOM_EVENT_NAME = "emoji-rain";
export default function EmojiRain() {
const canvasRef = useRef>(null);
const [emoji, setEmoji] = useState(null);
const timeoutRef = useRef();
useEffect(() => {
if (!emoji) {
return;
}
if (canvasRef.current === null) {
return;
}
const w = window.innerWidth;
const h = window.innerHeight;
const ctx = canvasRef.current.getContext("2d");
const bodyBackground = window.getComputedStyle(
document.body
).backgroundColor;
canvasRef.current.width = w;
canvasRef.current.height = h;
ctx.fillStyle = bodyBackground;
ctx.fillRect(0, 0, w - 1, h - 1);
const numOfEmojis = randomBetween(20, 30);
let emojis: ElementPosition[] = Array(numOfEmojis)
.fill(emoji)
.map((emoji) => {
let x = randomBetween(0, w - 1);
let y = randomBetween(-42, -600);
let size = randomBetween(32, 60);
return new ElementPosition(emoji, x, y, size, size);
});
let frame = window.requestAnimationFrame(function draw() {
const bodyBackground = window.getComputedStyle(
document.body
).backgroundColor;
emojis.forEach((element) => {
element.updateY(
interpolate(element.y, { min: -42, max: h }, { min: 8, max: 1 })
);
});
emojis = emojis.filter((elem) => {
if (elem.y > h + 42) {
return false;
}
return true;
});
ctx.fillStyle = bodyBackground;
ctx.fillRect(0, 0, w - 1, h - 1);
emojis.forEach((elem) => {
const alpha = interpolate(
elem.y,
{ min: 0, max: (h / 3) * 2 },
{ min: 1, max: 0 }
);
ctx.fillStyle = `rgb(17, 24, 39, ${alpha})`;
ctx.font = elem.width + "px sans";
ctx.fillText(elem.emoji, elem.x, elem.y);
});
frame = window.requestAnimationFrame(draw);
});
return () => {
ctx.fillStyle = bodyBackground;
ctx.fillRect(0, 0, w - 1, h - 1);
window.cancelAnimationFrame(frame);
};
}, [emoji]);
useEffect(() => {
function handleEmoji(e: CustomEvent) {
setEmoji(e.detail);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setEmoji(null);
}, 5000);
}
window.addEventListener(CUSTOM_EVENT_NAME, handleEmoji);
return () => window.removeEventListener(CUSTOM_EVENT_NAME, handleEmoji);
}, []);
return ;
}
export function rainEmoji(emoji: string) {
const event = new CustomEvent(CUSTOM_EVENT_NAME, { detail: emoji });
window.dispatchEvent(event);
}
interface RangeValue {
min: number;
max: number;
}
function interpolate(current: number, from: RangeValue, to: RangeValue) {
if (current < from.min) {
return to.min;
}
if (current > from.max) {
return to.max;
}
// y-y1/y2-y1 = x-x1/x2-x1
// y = x-x1/x2-x1*y2-y1 + y1
return (
((current - from.min) / (from.max - from.min)) * (to.max - to.min) + to.min
);
}
function randomBetween(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
class ElementPosition {
emoji: string;
x: number;
y: number;
width: number;
height: number;
velocity: number;
constructor(
emoji: string,
x: number,
y: number,
width: number,
height: number
) {
this.emoji = emoji;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
updateY(velocity: number) {
this.y = this.y + velocity;
}
}
================================================
FILE: components/EmojiSelector.tsx
================================================
import { Menu, MenuButton, MenuList, MenuItem } from "@reach/menu-button";
import { memo } from "react";
interface Props {
onSendEmoji: (emoji: string) => void;
}
function EmojiSelector(props: Props) {
return (
);
}
export default memo(EmojiSelector);
function EmojiBar({ onSelect }) {
const emojis = ["🎉", "🥳", "😂", "😭", "❤️", "🤬"];
return (
{emojis.map((emoji) => (
onSelect(emoji)}
>
{emoji}
))}
);
}
================================================
FILE: components/Header.tsx
================================================
import Head from "next/head";
import dynamic from "next/dynamic";
import React, { ReactNode } from "react";
const EmojiSelector = dynamic(() => import("./EmojiSelector"), { ssr: false });
interface Props {
title?: string;
description?: string;
keywords?: string[];
ogImage?: string;
customHeading?: ReactNode;
warnStorageDisabled?: boolean;
isLiveMode?: boolean;
themeColor?: string;
showLiarOption?: boolean;
onShowStats?: () => void;
onShowHelp?: () => void;
onShowSettings?: () => void;
onSendEmoji?: (emoji: string) => void;
path?: string;
}
export default function Header(props: Props) {
const {
title = "Katla - Permainan Tebak Kata | 1 Hari 1 Kata 6 Kesempatan",
description = "Tebak kata rahasia dalam 6 percobaan. Kata baru tersedia setiap hari.",
keywords = [
"game",
"permainan",
"main",
"tebak",
"kata",
"rahasia",
"sembunyi",
"clue",
"petunjuk",
"wordle",
"bahasa",
"indonesia",
"karya",
"anak",
"bangsa",
"kbbi",
],
ogImage = "https://katla.vercel.app/og.png",
customHeading,
onShowStats,
onShowHelp,
onShowSettings,
onSendEmoji,
warnStorageDisabled,
isLiveMode,
themeColor = "#15803D",
showLiarOption,
path = "/",
} = props;
return (
);
}
================================================
FILE: components/HeadingWithNum.tsx
================================================
interface Props {
num: string | number | null;
enableLiarMode?: boolean;
}
export default function HeadingWithNum(props: Props) {
const [_, mm, dd] = new Date().toISOString().split("T")[0].split("-");
const isIndonesiaIndependenceDay = mm === "08" && dd === "17";
let customNumClass = "";
if (isIndonesiaIndependenceDay) {
customNumClass = "text-white bg-red-500 p-1";
}
return (
{props.enableLiarMode ? "Katlie" : <>K atla >}
{props.num && (
#{props.num}
)}
);
}
================================================
FILE: components/HelpModal.tsx
================================================
import { memo } from "react";
import Modal from "./Modal";
import Tile from "./Tile";
interface Props {
isOpen: boolean;
onClose: () => void;
isLiveMode?: boolean;
}
export default memo(HelpModal);
function HelpModal(props: Props) {
const { isOpen, onClose, isLiveMode = false } = props;
return (
Cara Bermain
Tebak Katla dalam 6 kesempatan.
{isLiveMode ? (
Mainkan bersama teman-temanmu (maksimum 10 orang). Setelah
permainan selesai, babak baru akan otomatis dimulai dalam 5 detik.
) : (
1 hari ada 1 kata rahasia.
)}
Setiap tebakan harus merupakan kata valid 5 huruf sesuai KBBI. Tekan
tombol ENTER untuk mengirimkan jawaban
Setelah jawaban dikirimkan, warna kotak akan berubah untuk menunjukkan
seberapa dekat tebakanmu dari kata rahasia
Contoh
{"semua".split("").map((char, i) => {
return (
);
})}
Huruf S ada dan posisinya sudah tepat
{"kasur".split("").map((char, i) => {
return (
);
})}
Huruf A ada namun posisinya belum tepat
{"duduk".split("").map((char, i) => {
return (
);
})}
Tidak ada huruf K di kata rahasia
{isLiveMode ? null : (
Akan ada Katla baru setiap
hari!
)}
);
}
================================================
FILE: components/Keyboard.tsx
================================================
import { MutableRefObject, useEffect, useRef } from "react";
import KeyboardButton from "./KeyboardButton";
import { AnswerState, GameState } from "../utils/types";
import { decode } from "../utils/codec";
interface Props {
onPressChar: (char: string) => void;
onBackspace: () => void;
onSubmit: () => void;
gameState: GameState;
hash: string;
isAnimating: MutableRefObject;
}
export default function Keyboard(props: Props) {
const { onPressChar, onBackspace, onSubmit, gameState, hash, isAnimating } =
props;
const answer = decode(hash);
const usedChars = new Set(
gameState.answers
.slice(0, gameState.attempt)
.map((answer) => answer.split(""))
.flat()
);
const correctChars = new Set();
gameState.answers.forEach((userAnswer, i) => {
if (i < gameState.attempt) {
userAnswer.split("").forEach((char, j) => {
if (answer[j] === char) {
correctChars.add(char);
}
});
}
});
function getKeyboardState(char: string): AnswerState {
let state = null;
if (correctChars.has(char)) {
state = "c";
} else if (usedChars.has(char) && answer.includes(char)) {
state = "e";
} else if (usedChars.has(char)) {
state = "w";
}
return gameState.enableLiarMode ? null : state;
}
const pressed = useRef(null);
useEffect(() => {
function handleKeydown(e: KeyboardEvent) {
if (gameState.attempt === 6) {
return;
}
const currentText = gameState.answers[gameState.attempt];
if (
pressed.current === true &&
e.key === currentText[currentText.length - 1]
) {
return;
}
if (isAnimating.current) {
return;
}
pressed.current = true;
if (e.key === "Backspace") {
onBackspace();
} else if (e.key === "Enter") {
// prevent modal to be opened when pressing enter
e.preventDefault();
onSubmit();
} else if (gameState.enableFreeEdit && e.key === "_") {
onPressChar(e.key);
} else if (/[a-z]/i.test(e.key) && e.key.length === 1) {
onPressChar(e.key);
}
}
function handleKeyup() {
pressed.current = false;
}
document.addEventListener("keydown", handleKeydown);
document.addEventListener("keyup", handleKeyup);
return () => {
document.removeEventListener("keydown", handleKeydown);
document.removeEventListener("keyup", handleKeyup);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameState]);
return (
{"qwertyuiop".split("").map((char) => (
onPressChar(char)}
>
{char}
))}
{"asdfghjkl".split("").map((char) => (
onPressChar(char)}
>
{char}
))}
Enter
{"zxcvbnm".split("").map((char) => (
onPressChar(char)}
>
{char}
))}
{gameState.enableFreeEdit && (
onPressChar("_")}
scale={1.5}
>
_
)}
);
}
================================================
FILE: components/KeyboardButton.tsx
================================================
import { ComponentProps, memo } from "react";
import { AnswerState } from "../utils/types";
type Props = {
state: AnswerState;
scale?: number;
} & Omit, "className" | "style">;
export default function KeyboardButton(props: Props) {
let color = "bg-gray-300 text-gray-900 dark:bg-gray-500 dark:text-gray-200";
switch (props.state) {
case "c":
color = "text-white bg-correct";
break;
case "e":
color = "text-white bg-exist";
break;
case "w":
color = "text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700";
break;
default:
}
return (
);
}
================================================
FILE: components/Link.tsx
================================================
import NextLink from "next/link";
import { ComponentProps } from "react";
export default function Link(props: ComponentProps) {
const { children, ...rest } = props;
return (
{children}
);
}
================================================
FILE: components/LiveStatsModal.tsx
================================================
import Modal from "./Modal";
import Alert from "./Alert";
import { useMap, useOthers, useSelf } from "@liveblocks/react";
interface Props {
isOpen: boolean;
onClose: () => void;
totalPlay: number;
}
const GRAPH_WIDTH_MIN_RATIO = 10;
export default function LiveStatsModal(props: Props) {
const others = useOthers();
const self = useSelf();
const { isOpen, onClose, totalPlay } = props;
const users = others
.toArray()
.concat(self)
.filter(Boolean)
.sort((a, b) => {
return (a.presence?.winCount ?? 0) > (b.presence?.winCount ?? 0) ? -1 : 1;
});
return (
Statistik
Distribusi Kemenangan
{users.map((user) => {
const shouldHighlight = user.connectionId === self.connectionId;
const winCount = user.presence?.winCount ?? 0;
const ratio =
totalPlay === 0
? GRAPH_WIDTH_MIN_RATIO
: Math.max((winCount / totalPlay) * 100, GRAPH_WIDTH_MIN_RATIO);
const alignment =
ratio === GRAPH_WIDTH_MIN_RATIO
? "justify-center"
: "justify-end";
const background = shouldHighlight ? "bg-accent" : "bg-gray-500";
return (
);
})}
);
}
================================================
FILE: components/Modal.tsx
================================================
import { DialogOverlay, DialogContent } from "@reach/dialog";
import { useEffect, useState, ReactNode, useCallback } from "react";
import { getTotalPlay, isGameFinished } from "../utils/game";
import { GameStats, Game } from "../utils/types";
interface Props {
isOpen: boolean;
onClose?: () => void;
children: ReactNode;
}
export default function Modal(props: Props) {
const { isOpen, onClose, children } = props;
return (
);
}
const Title = ({ children }) => (
{children}
);
Modal.Title = Title;
type ModalState = "help" | "stats" | "settings";
type ModalStateReturn = [ModalState, (state: ModalState) => void, () => void];
export function useModalState(game: Game, stats: GameStats): ModalStateReturn {
const [modalState, setModalState] = useState(null);
useEffect(() => {
if (!game.ready) {
return;
}
// show help screen for first-time player
if (
getTotalPlay(stats) === 0 &&
game.state.attempt === 0 &&
game.state.answers[0] === ""
) {
setModalState("help");
}
// show stats screen if user already finished playing current session
else if (isGameFinished(game)) {
setModalState("stats");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [game.ready]);
useEffect(() => {
// reset modal every new game
setModalState(null);
}, [game.num]);
const resetModalState = useCallback(() => {
setModalState(null);
}, []);
return [modalState, setModalState, resetModalState];
}
================================================
FILE: components/SettingsModal.tsx
================================================
import { useTheme } from "next-themes";
import Link from "./Link";
import Modal from "./Modal";
import Alert from "./Alert";
import {
GAME_STATE_KEY,
GAME_STATS_KEY,
INVALID_WORDS_KEY,
LAST_HASH_KEY,
LAST_SESSION_RESET_KEY,
} from "../utils/constants";
import LocalStorage from "../utils/browser";
import { ForcedResult, Game, LiveConfig } from "../utils/types";
import { shareInviteLink } from "../utils/liveGame";
interface Props {
isOpen: boolean;
onClose: () => void;
game: Game;
liveConfig?: LiveConfig;
}
export default function SettingsModal(props: Props) {
const { game, isOpen, onClose, liveConfig } = props;
const { resolvedTheme, setTheme } = useTheme();
const showLiarMode = game.num === 71;
const correctColor = game.state.enableHighContrast ? "biru" : "hijau";
const incorrectColor = game.state.enableHighContrast ? "oranye" : "kuning";
return (
Pengaturan
{game.num !== -1 && (
{
game.setState({ ...game.state, enableHardMode });
}}
/>
)}
{
setTheme(active ? "dark" : "light");
}}
/>
{
game.setState({ ...game.state, enableHighContrast });
}}
/>
{
game.setState({ ...game.state, enableFreeEdit });
}}
/>
{showLiarMode && (
{
game.setState({
...game.state,
enableLiarMode,
lieBoxes: generateLieBoxes(),
});
}}
/>
)}
{liveConfig ? (
liveConfig.isHost ? (
) : (
)
) : (
)}
);
}
interface AdminToolsProps {
game: Game;
onClose: () => void;
config: LiveConfig;
}
function AdminTools(props: AdminToolsProps) {
const { game, onClose, config } = props;
function handleReset() {
game.resetState();
onClose();
}
function handleInvite() {
shareInviteLink(config, onClose);
}
return (
Mode Lawan
Ajak pemain
Mulai dari awal
);
}
interface PlayerToolsProps {
config: LiveConfig;
}
function PlayerTools(props: PlayerToolsProps) {
const { config } = props;
function handleInvite() {
shareInviteLink(config);
}
return (
Pengaturan Mode Lawan
Ajak pemain
);
}
function AdditionalInformation() {
function handleReset() {
LocalStorage.removeItem(GAME_STATE_KEY);
LocalStorage.removeItem(GAME_STATS_KEY);
LocalStorage.removeItem(INVALID_WORDS_KEY);
LocalStorage.removeItem(LAST_HASH_KEY);
LocalStorage.setItem(
LAST_SESSION_RESET_KEY,
new Date().getTime().toString()
);
window.location.reload();
}
return (
<>
Informasi
Katla merupakan imitasi adaptasi dari{" "}
Wordle
Kamu bisa melihat daftar kata yang telah digunakan sebelumnya di dalam{" "}
Arsip
Terdapat Masalah?
Bantuan
atau
reset sesi sekarang
>
);
}
interface SwitchProps {
active: boolean;
title: string;
subtitle?: string;
onlyEnableOnFirstAttempt?: boolean;
attempt?: number;
isExperimental?: boolean;
onChange: (active: boolean) => void;
}
function Switch(props: SwitchProps) {
const {
title,
subtitle,
isExperimental,
active,
onChange,
onlyEnableOnFirstAttempt,
attempt,
} = props;
const disabled = onlyEnableOnFirstAttempt && attempt > 0;
const disabledText = `${title} hanya dapat diganti di awal permainan`;
const warningText = onlyEnableOnFirstAttempt
? `Hanya dapat diganti di awal permainan`
: isExperimental
? "Masih dalam tahap uji coba"
: "";
function handleClick() {
if (disabled) {
Alert.show(disabledText, { id: "disabled" });
return;
}
onChange(!active);
}
return (
{title}{" "}
{warningText && (
{"(" + warningText + ")"}
)}
{subtitle &&
{subtitle} }
);
}
function generateLieBoxes(): ForcedResult[] {
// only 5 because we want the last one to show the real answer
return Array(5)
.fill(0)
.map(() => {
const isExist = Math.random() > 0.35;
const isCorrect = Math.random() > 0.5;
const col = Math.floor(Math.random() * 5);
return isExist ? (isCorrect ? [col, "c"] : [col, "e"]) : [col, "w"];
});
}
================================================
FILE: components/SponsorshipFooter.tsx
================================================
export default function SponsorshipFooter() {
return (
);
}
================================================
FILE: components/StatsModal.tsx
================================================
import useSWR from "swr";
import { LegacyRef, useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import Modal from "./Modal";
import { AnswerState, Game, GameStats } from "../utils/types";
import { decode } from "../utils/codec";
import fetcher from "../utils/fetcher";
import { pad0 } from "../utils/formatter";
import {
useRemainingTime,
getTotalPlay,
getTotalWin,
getAnswerStates,
} from "../utils/game";
import { checkNativeShareSupport, shareText } from "../utils/browser";
import { isEidMessage } from "../utils/message";
interface Props {
isOpen: boolean;
onClose: () => void;
game: Game;
stats: GameStats;
remainingTime?: ReturnType;
}
declare global {
interface Window {
adsbygoogle: { loaded: boolean; push: (v: unknown) => void };
}
}
const GRAPH_WIDTH_MIN_RATIO = 10;
export default function StatsModal(props: Props) {
const { isOpen, onClose, game, stats } = props;
const { resolvedTheme } = useTheme();
const isAnswered =
game.state.answers[game.state.attempt - 1] === decode(game.hash);
const [canShareImage, setCanShareImage] = useState(false);
const [showAnswersCheckbox, setShowAnswersCheckbox] = useState(false);
useEffect(() => {
const canShareImage =
checkNativeShareSupport() && typeof navigator.canShare === "function";
setCanShareImage(canShareImage);
if (!canShareImage) {
// preload image share logic
import("file-saver");
}
}, []);
const answer = decode(game.hash);
const showShare =
game.state.attempt === 6 ||
game.state.answers[game.state.attempt - 1] === answer;
const totalWin = getTotalWin(stats);
const totalPlay = getTotalPlay(stats);
const title = game.state.enableLiarMode ? "Katlie" : "Katla";
function generateText() {
const hardModeMarker = game.state.enableHardMode ? "*" : "";
const score =
game.state.answers[game.state.attempt - 1] === answer
? game.state.attempt
: "X";
let text = `${title} ${game.num} ${score}/6${hardModeMarker}\n\n`;
game.state.answers.filter(Boolean).forEach((userAnswer) => {
const answerEmojis = getAnswerStates(userAnswer, answer).map((state) => {
switch (state) {
case "c":
return game.state.enableHighContrast ? "🟧" : "🟩";
case "e":
return game.state.enableHighContrast ? "🟦" : "🟨";
case "w":
return resolvedTheme === "dark" ? "⬛" : "⬜️";
}
});
text += `${answerEmojis.join("")}\n`;
});
if (
game.num === 102 &&
isEidMessage(game.state.answers[game.state.attempt - 1])
) {
text = `🙏🙏🙏🙏🙏\n\n`;
text += `⬛⬛🟩⬛⬛\n`;
text += `⬛🟩🟨🟩⬛\n`;
text += `🟩🟨🟩🟨🟩\n`;
text += `⬛🟩🟨🟩⬛\n`;
text += `⬛⬛🟩⬛⬛\n`;
text += "\n" + window.location.href;
return text;
}
text += "\n" + window.location.href;
return text;
}
function handleShare() {
shareText(generateText(), { cb: onClose });
}
async function handleShareImage() {
const canvas = document.createElement("canvas");
canvas.height = 1400;
canvas.width = 900;
const ctx = canvas.getContext("2d");
ctx.fillStyle = resolvedTheme === "dark" ? "#111827" : "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const gap = 10;
const paddingH = 200;
const paddingT = 400;
const size = (canvas.width - paddingH * 2 - gap * 4) / 5;
const score =
game.state.answers[game.state.attempt - 1] === answer
? game.state.attempt
: "X";
const hardModeMarker = game.state.enableHardMode ? "*" : "";
let answers = game.state.answers.slice(0, game.state.attempt);
let answerStates: AnswerState[][] = answers.map((answer) => {
return getAnswerStates(answer, decode(game.hash));
});
let text = `${title} ${game.num} ${score}/6${hardModeMarker}\n\n`;
if (
game.num === 102 &&
isEidMessage(game.state.answers[game.state.attempt - 1])
) {
text = `🙏🙏🙏🙏🙏🙏`;
answers = ["mohon", "maaf", "lahir", "dan", "batin"];
answerStates = [
["w", "w", "c", "w", "w"],
["w", "c", "e", "c", "w"],
["c", "e", "c", "e", "c"],
["w", "c", "e", "c", "w"],
["w", "w", "c", "w", "w"],
];
}
ctx.font = "bold 42px sans-serif";
ctx.textAlign = "center";
ctx.fillStyle = resolvedTheme === "dark" ? "#ffffff" : "#111827";
ctx.fillText(text, canvas.width / 2, 300);
ctx.font = "32px sans-serif";
ctx.fillText("katla.vercel.app", canvas.width / 2, canvas.height - 150);
answerStates.forEach((states, y) => {
const answer = answers[y];
states.forEach((state, x) => {
if (state === "c") {
ctx.fillStyle = game.state.enableHighContrast ? "#f5793a" : "#15803d";
} else if (state === "e") {
ctx.fillStyle = game.state.enableHighContrast ? "#85c0f9" : "#ca8a04";
} else {
ctx.fillStyle = resolvedTheme === "dark" ? "#374151" : "#6b7280";
}
const marginH = gap * x;
const marginV = gap * y;
const rectX = paddingH + x * size + marginH;
const rectY = paddingT + y * size + marginV;
ctx.beginPath();
ctx.rect(rectX, rectY, size, size);
ctx.fill();
if (showAnswersCheckbox) {
ctx.font = "bold 54px sans-serif";
ctx.fillStyle = "#ffffff";
ctx.fillText(
answer[x]?.toUpperCase() ?? "",
rectX + size / 2,
rectY + size / 1.5
);
}
});
});
const dataURL = canvas.toDataURL();
const blob = await (await fetch(dataURL)).blob();
if (canShareImage) {
const imageName = showAnswersCheckbox
? `katla-${game.num}-with-answers.jpg`
: `katla-${game.num}.jpg`;
const shareData = {
files: [
new File([blob], imageName, {
type: "image/jpeg",
lastModified: new Date().getTime(),
}),
],
};
navigator.share(shareData).catch(() => {});
} else {
const { saveAs } = await import("file-saver").then((mod) => mod.default);
const imageName = showAnswersCheckbox
? `katla-${game.num}-with-answers`
: `katla-${game.num}`;
saveAs(blob, imageName);
}
}
function handleShareToTwitter() {
const text = generateText();
const encodeURI = text.replace(/\n/g, "%0A");
const shareToTwitter = `https://twitter.com/intent/tweet?text=${encodeURI}`;
window.open(shareToTwitter, "_blank");
}
const { fail: _, ...distribution } = stats.distribution;
const maxDistribution = Math.max(...Object.values(distribution));
const [isAdUnitRendered, setIsAdUnitRendered] = useState(false);
const adsByGooglePushedRef = useRef(false);
const adUnitRef = useRef(null);
useEffect(() => {
if (!isOpen || !isAdUnitRendered || adsByGooglePushedRef.current) return;
const adUnit = adUnitRef.current;
adUnit.innerHTML = `
`;
try {
// @ts-ignore
window.adsbygoogle = (window.adsbygoogle || []).push({});
adsByGooglePushedRef.current = true;
} catch (err) {
// ignore
}
}, [isOpen, isAdUnitRendered]);
const adUnitRefCallback: LegacyRef = (element) => {
adUnitRef.current = element;
setIsAdUnitRendered(!!element);
};
return (
Statistik
{totalPlay === 0 ? 0 : Math.round((totalWin / totalPlay) * 100)}
% Menang
{stats.currentStreak}
Runtunan saat ini
{stats.maxStreak}
Runtunan maksimum
Distribusi Tebakan
{Array(6)
.fill("")
.map((_, i) => {
const shouldHighlight = isAnswered && i === game.state.attempt - 1;
const ratio =
totalWin === 0
? GRAPH_WIDTH_MIN_RATIO
: Math.max(
(Number(stats.distribution[i + 1]) / maxDistribution) * 100,
GRAPH_WIDTH_MIN_RATIO
);
const alignment =
ratio === GRAPH_WIDTH_MIN_RATIO
? "justify-center"
: "justify-end";
const background = shouldHighlight ? "bg-accent" : "bg-gray-500";
return (
{i + 1}
{stats.distribution[i + 1]}
);
})}
{showShare && (
<>
{props.remainingTime ? (
) : (
)}
Share
Image
{canShareImage ? (
) : (
)}
setShowAnswersCheckbox(e.target.checked)}
/>
Sertakan jawaban pada gambar
Tweet
>
)}
);
}
function WordDefinition({ answer }) {
const { data = [] } = useSWR(`/api/define/${answer}`, (path) => {
return fetcher(path, {
headers: {
Authorization: `token ${process.env.NEXT_PUBLIC_DEFINE_TOKEN}`,
},
});
});
return (
Katla hari ini
Mohon untuk tetap dirahasiakan, semua orang mendapatkan kata yang sama
🙏
{answer}
{data.length > 0 ? (
data.length === 1 ? (
`: ${data[0]}`
) : (
{data.map((d, i) => (
{d}
))}
)
) : null}
Lihat di KBBI
);
}
function TimeCounter({ time }: { time: ReturnType }) {
const remainingTime = `${time.hours}:${pad0(time.minutes)}:${pad0(
time.seconds
)}`;
return (
Katla berikutnya
{remainingTime}
);
}
================================================
FILE: components/Tile.tsx
================================================
import { CSSProperties, useEffect, useState } from "react";
import {
FLIP_ANIMATION_DELAY_MS,
FLIP_ANIMATION_DURATION_MS,
SHAKE_ANIMATION_DURATION_MS,
} from "../utils/constants";
import { AnswerState } from "../utils/types";
interface Props {
char: string;
state: AnswerState;
isInvalid?: boolean;
delay: number;
onPress?: () => void;
}
export default function Tile(props: Props) {
const [background, setBackground] = useState("text-gray-700 dark:text-white");
const [animate, setAnimationEnabled] = useState(false);
const border =
props.char === " " ? "border" : props.state === null ? "border-3" : "";
const borderColor =
props.char === " "
? "dark:border-gray-700 border-gray-400"
: props.state === null
? "border-gray-500"
: "";
useEffect(() => {
if (props.state === null) {
return;
}
setAnimationEnabled(true);
() => setAnimationEnabled(false);
}, [props.state]);
const style: CSSProperties = {};
if (props.isInvalid) {
style.animationName = "shake";
style.animationDuration = `${SHAKE_ANIMATION_DURATION_MS}ms`;
}
if (animate) {
style.animationName = "flip";
style.animationDuration = `${FLIP_ANIMATION_DURATION_MS}ms`;
style.animationDelay = `${props.delay}ms`;
}
useEffect(() => {
setTimeout(() => {
switch (props.state) {
case "c":
setBackground("text-white dark:text-gray-200 bg-correct");
break;
case "e":
setBackground("text-white bg-exist");
break;
case "w":
setBackground(
"text-white bg-gray-500 dark:text-gray-200 dark:bg-gray-700"
);
break;
}
}, props.delay + FLIP_ANIMATION_DELAY_MS);
}, [animate, props.state, props.delay]);
return (
{props.char === "_" ? "" : props.char}
);
}
================================================
FILE: env.dev
================================================
NEXT_PUBLIC_DEFINE_TOKEN="test"
================================================
FILE: jest.config.js
================================================
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/s9/v3c_29w146qcg2v7f_pwmb3c0000gn/T/jest_dx",
// Automatically clear mock calls, instances and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// ""
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
transform: {
"^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};
================================================
FILE: next-env.d.ts
================================================
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: next.config.js
================================================
const { withSentryConfig } = require("@sentry/nextjs");
const nextConfig = {
reactStrictMode: true,
};
const sentryWebpackPluginOptions = {
silent: true,
};
module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);
================================================
FILE: package.json
================================================
{
"name": "katla",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"update": "node ./.scripts/update.js",
"format": "prettier --write '**/*.{js,md,json,yml,yaml,css,ts,tsx}'",
"prepare": "husky install"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@liveblocks/client": "^0.14.1",
"@liveblocks/node": "^0.3.0",
"@liveblocks/react": "^0.14.1",
"@reach/dialog": "^0.16.2",
"@reach/menu-button": "^0.16.2",
"@sentry/nextjs": "^6.17.4",
"@supabase/supabase-js": "^1.30.7",
"canvas-confetti": "^1.4.0",
"cheerio": "^1.0.0-rc.10",
"date-fns": "^2.28.0",
"file-saver": "^2.0.5",
"next": "^12.1.0",
"next-themes": "^0.0.16",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-hot-toast": "^2.2.0",
"swr": "^1.1.2"
},
"devDependencies": {
"@octokit/rest": "^18.12.0",
"@testing-library/react-hooks": "^7.0.2",
"@types/react": "^17.0.38",
"autoprefixer": "^10.4.2",
"eslint": "8.7.0",
"eslint-config-next": "12.1.0",
"husky": "^7.0.4",
"jest": "^27.4.7",
"lint-staged": "^12.2.2",
"postcss": "^8.4.5",
"prettier": "^2.5.1",
"tailwindcss": "^3.0.15",
"typescript": "^4.5.4"
}
}
================================================
FILE: pages/_app.tsx
================================================
import Script from "next/script";
import "../styles/globals.css";
import { ThemeProvider } from "next-themes";
import Alert from "../components/Alert";
import EmojiRain from "../components/EmojiRain";
export default function MyApp({ Component, pageProps }) {
return (
);
}
================================================
FILE: pages/_document.tsx
================================================
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
);
}
================================================
FILE: pages/_error.js
================================================
import NextErrorComponent from "next/error";
import * as Sentry from "@sentry/nextjs";
const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {
if (!hasGetInitialPropsRun && err) {
// getInitialProps is not called in case of
// https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
// err via _app.js so it can be captured
Sentry.captureException(err);
// Flushing is not required in this case as it only happens on the client
}
return ;
};
MyError.getInitialProps = async (context) => {
const errorInitialProps = await NextErrorComponent.getInitialProps(context);
const { res, err, asPath } = context;
// Workaround for https://github.com/vercel/next.js/issues/8592, mark when
// getInitialProps has run
errorInitialProps.hasGetInitialPropsRun = true;
// Returning early because we don't want to log 404 errors to Sentry.
if (res?.statusCode === 404) {
return errorInitialProps;
}
// Running on the server, the response object (`res`) is available.
//
// Next.js will pass an err on the server if a page's data fetching methods
// threw or returned a Promise that rejected
//
// Running on the client (browser), Next.js will provide an err if:
//
// - a page's `getInitialProps` threw or returned a Promise that rejected
// - an exception was thrown somewhere in the React lifecycle (render,
// componentDidMount, etc) that was caught by Next.js's React Error
// Boundary. Read more about what types of exceptions are caught by Error
// Boundaries: https://reactjs.org/docs/error-boundaries.html
if (err) {
Sentry.captureException(err);
// Flushing before returning is necessary if deploying to Vercel, see
// https://vercel.com/docs/platform/limits#streaming-responses
await Sentry.flush(2000);
return errorInitialProps;
}
// If this point is reached, getInitialProps was called without any
// information about what the error might be. This is unexpected and may
// indicate a bug introduced in Next.js, so record it in Sentry
Sentry.captureException(
new Error(`_error.js getInitialProps missing data at path: ${asPath}`)
);
await Sentry.flush(2000);
return errorInitialProps;
};
export default MyError;
================================================
FILE: pages/api/define/[word].ts
================================================
import { withSentry } from "@sentry/nextjs";
import cheerio from "cheerio";
import { NextApiRequest, NextApiResponse } from "next";
interface Definition {
def_text: string;
}
interface KategloResponse {
kateglo: {
definition: ArrayLike;
};
}
const tokens = [
process.env.NEXT_PUBLIC_DEFINE_TOKEN,
process.env.THIRD_PARTY_DEFINE_TOKEN,
];
async function handler(req: NextApiRequest, res: NextApiResponse) {
const word = req.query.word as string;
const auth = req.headers.authorization;
if (!auth || !auth.startsWith("token")) {
return res.status(401).json({ error: "Unauthorized" });
}
const [_, token] = auth.split(" ");
if (!tokens.includes(token)) {
return res.status(401).json({ error: "Unauthorized" });
}
let definitions: String[] | null = null;
try {
try {
definitions = await fetchFromMakna(word);
} catch (err) {
console.log(`Failed to fetch from makna, using KBBI for word ${word}`, {
err,
});
definitions = await fetchFromKbbi(word);
}
} catch (err) {
console.warn(
`Failed to fetch definitions from KBBI, using kateglo.com for word ${word}`,
{ err }
);
try {
const kateglo: KategloResponse = await fetch(
`https://kateglo.com/api.php?format=json&phrase=${word}`
).then((res) => res.json());
definitions = Array.from(kateglo.kateglo.definition).map(
(d) => d.def_text
);
} catch (err) {
definitions = null;
}
}
if (definitions === null) {
res.status(500).json({ error: "Failed to get definitions" });
return;
}
res.setHeader(
"Cache-Control",
"public, s-maxage=21600, stale-while-revalidate=86400"
);
res.status(200).json(definitions);
}
export default withSentry(handler);
async function fetchFromMakna(word: string): Promise {
const json = await fetch(
`https://makna.fatihkalifa.workers.dev/${word}.json`
).then((res) => res.json());
return json.flatMap((entry) => {
return entry.makna.map((makna) => makna.definisi);
});
}
async function fetchFromKbbi(word: string): Promise {
const html = await fetch(`https://kbbi.kemdikbud.go.id/entri/${word}`).then(
(res) => res.text()
);
const $ = cheerio.load(html);
const definitions = [];
$("ol li, ul.adjusted-par li").each((i, el) => {
$(el).find("font").remove();
definitions.push($(el).text());
});
return definitions;
}
================================================
FILE: pages/api/live.ts
================================================
import { authorize } from "@liveblocks/node";
import { createClient } from "@supabase/supabase-js";
import { NextApiRequest, NextApiResponse } from "next";
const supabase = createClient(
"https://wwaoyidihlwhhlzykzup.supabase.co",
process.env.SUPABASE_SECRET
);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const room = req.body.room;
const auth = req.body.auth;
const { data, error } = await supabase
.from("rooms")
.select()
.or(`auth.eq.${auth},invite.eq.${auth}`);
if (error || data.length === 0) {
return res
.status(403)
.json({ error: "You don't have permission to access this room" });
}
const result = await authorize({
room,
secret: process.env.LIVEBLOCKS_SECRET_KEY,
userId: req.body.username,
});
return res.status(result.status).json({
...JSON.parse(result.body),
inviteKey: data[0].invite,
});
}
================================================
FILE: pages/api/words.ts
================================================
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const kbbi = await fetch("https://kbbi.vercel.app").then((res) => res.json());
const words = kbbi.entries
.map((entry) => {
const [word] = entry.split("/").reverse();
return word;
})
.filter((word) => /^[a-z]+$/.test(word) && word.length === 5)
.concat(october2021)
.sort();
res.setHeader(
"Cache-Control",
"public, s-maxage=3600, stale-while-revalidate=86400"
);
res.status(200).json(Array.from(new Set(words)));
}
const october2021 = [
"abate",
"abjat",
"ampet",
"arame",
"asrot",
"azeri",
"azuki",
"bakes",
"benin",
"beton",
"bezit",
"bksda",
"botia",
"burma",
"cekah",
"cekat",
"cenah",
"colon",
"dapuk",
"datau",
"datin",
"datum",
"denar",
"dobra",
"doula",
"duvet",
"filem",
"folio",
"gabut",
"gayor",
"gokil",
"gravi",
"hipmi",
"ilyas",
"impun",
"india",
"islan",
"kabul",
"kalke",
"kazak",
"korea",
"kudet",
"kurdi",
"kwaca",
"lakip",
"mandu",
"maori",
"nuget",
"ompok",
"palau",
"panda",
"pasto",
"porus",
"rasis",
"rouks",
"rupst",
"sango",
"sapun",
"silpa",
"sonde",
"struk",
"sudan",
"swazi",
"tafia",
"tajik",
"tando",
"tenge",
"titis",
"tonga",
"tuhur",
"uwete",
"uzbek",
"venda",
];
================================================
FILE: pages/arsip/[num].tsx
================================================
import { GetStaticPaths, GetStaticProps } from "next";
import { ComponentProps, useState } from "react";
import App from "../../components/App";
import Container from "../../components/Container";
import Header from "../../components/Header";
import HeadingWithNum from "../../components/HeadingWithNum";
import HelpModal from "../../components/HelpModal";
import { useModalState } from "../../components/Modal";
import SettingsModal from "../../components/SettingsModal";
import StatsModal from "../../components/StatsModal";
import { getAllAnswers } from "../../utils/answers";
import { encodeHashed } from "../../utils/codec";
import fetcher from "../../utils/fetcher";
import { isGameFinished, useGame } from "../../utils/game";
import { handleGameComplete, handleSubmitWord } from "../../utils/message";
import { GameStats } from "../../utils/types";
interface Props {
num: string;
hashed: string;
words: string[];
}
const initialStats: GameStats = {
distribution: {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
fail: 0,
},
currentStreak: 0,
maxStreak: 0,
};
export default function Arsip(props: Props) {
const game = useGame(props.hashed, false);
const [stats, setStats] = useState(initialStats);
const [modalState, setModalState, resetModalState] = useModalState(
game,
stats
);
const headerProps: ComponentProps = {
title: `Katla | Arsip #${props.num}`,
customHeading: ,
path: `/arsip/${props.num}`,
ogImage: "https://katla.vercel.app/og-arsip.png",
themeColor: game.state.enableHighContrast ? "#f5793a" : "#15803D",
onShowHelp: () => setModalState("help"),
onShowStats: isGameFinished(game)
? () => setModalState("stats")
: undefined,
onShowSettings: () => setModalState("settings"),
};
if (!game.ready) {
return (
);
}
return (
setModalState("stats")}
words={props.words}
onSubmit={handleSubmitWord}
onComplete={handleGameComplete}
/>
);
}
// generate first and last 5 days
export const getStaticPaths: GetStaticPaths = async () => {
const answers = await getAllAnswers();
return {
paths: Array.from({ length: 5 }, (_, i) => ({
params: { num: `${i + 1}` },
})).concat(
Array.from({ length: 5 }, (_, i) => ({
params: { num: `${answers.length - i + 1}` },
}))
),
fallback: "blocking",
};
};
export const getStaticProps: GetStaticProps = async (ctx) => {
const num = ctx.params.num as string;
if (Number.isNaN(parseInt(num))) {
return {
notFound: true,
};
}
const numInt = Number(num);
const [answers, words] = await Promise.all([
getAllAnswers(),
fetcher("https://makna.fatihkalifa.workers.dev/words.json"),
]);
// archive should only return previous dates
if (numInt > answers.length) {
return {
notFound: true,
revalidate: 60,
};
}
return {
props: {
num,
hashed: encodeHashed(numInt, answers[numInt - 1], ""),
words,
},
};
};
================================================
FILE: pages/arsip/index.tsx
================================================
import { useState } from "react";
import Container from "../../components/Container";
import Header from "../../components/Header";
import HelpModal from "../../components/HelpModal";
import Link from "../../components/Link";
import SettingsModal from "../../components/SettingsModal";
import { getAllAnswers } from "../../utils/answers";
import { initialState, useGamePersistedState } from "../../utils/game";
import { Game } from "../../utils/types";
export default function Arsip({ nums }) {
const [modalState, setModalState] = useState(null);
const [gameState, setGameState] = useGamePersistedState(initialState);
const game: Game = {
hash: "",
num: -1,
migrate: () => {},
state: gameState,
setState: setGameState,
ready: true,
readyState: "ready" as const,
trackInvalidWord: () => {},
};
return (
);
}
export const getStaticProps = async () => {
const answers = await getAllAnswers();
return {
props: {
nums: answers.length - 1,
},
revalidate: 3600,
};
};
================================================
FILE: pages/bantuan.tsx
================================================
import { FormEvent, useEffect, useState } from "react";
import LocalStorage from "../utils/browser";
import {
GAME_STATE_KEY,
GAME_STATS_KEY,
INVALID_WORDS_KEY,
LAST_HASH_KEY,
LAST_SESSION_RESET_KEY,
} from "../utils/constants";
export default function Debug(props: { hashed: string }) {
const [debugCode, setDebugCode] = useState("");
useEffect(() => {
const gameState = LocalStorage.getItem(GAME_STATE_KEY);
const gameStats = LocalStorage.getItem(GAME_STATS_KEY);
const lastHash = LocalStorage.getItem(LAST_HASH_KEY);
const invalidWords = LocalStorage.getItem(INVALID_WORDS_KEY);
const lastSessionReset = LocalStorage.getItem(LAST_SESSION_RESET_KEY);
const now = new Date();
let timezone = "Unknown";
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (err) {}
setDebugCode(
btoa(
[
props.hashed,
lastHash,
gameState,
gameStats,
invalidWords,
lastSessionReset,
now.getTime(),
now.getTimezoneOffset(),
timezone,
navigator.userAgent,
window.location.host,
].join(":")
)
);
// eslint-disable-next-line
}, []);
const messagePrefix = `Halo, saya ingin melaporkan masalah tentang ...`;
const mailToLink = `mailto:help@katla.id?subject=Problem katla&body=${messagePrefix}%0D%0A%0D%0AKode: ${debugCode}`;
const confirmImport = (e: FormEvent) => {
e.preventDefault();
// @ts-ignore
const newDebugCode = e.target.debugCode.value;
let confirmed: boolean;
if (newDebugCode === debugCode) {
confirmed = window.confirm(
"Kode yang anda masukkan sama dengan kode di perangkat ini. Apakah anda yakin ingin mengimpor?"
);
} else {
confirmed = window.confirm(
"Statistik yang ada di perangkat ini akan diganti dari statistik dari kode. Apakah anda yakin?"
);
}
if (!confirmed) return;
try {
const decoded = atob(newDebugCode);
const st0 = decoded.indexOf('{"distribution');
const ste = decoded.indexOf("}:");
const stats = decoded.slice(st0, ste + 1);
LocalStorage.setItem(GAME_STATS_KEY, stats);
alert("Statistik berhasil diimpor");
window.location.replace("/");
} catch (err) {
console.error(err);
alert("Kode yang anda masukkan tidak valid");
}
};
return (
Bantuan
{debugCode === "" ? (
Generating debug code...
) : (
<>
Klik{" "}
tautan berikut
{" "}
untuk mengirim email.
Klik {/* eslint-disable-next-line */}
tautan berikut
{" "}
untuk kembali ke beranda
Kode bantuan
{debugCode}
>
)}
Impor Statistik
Masukkan debug code yang anda dapat dari halaman ini di perangkat lain
untuk mengimpor statistik dari perangkat tersebut
);
}
function NewSiteWarning() {
return (
Mulai 4 Oktober 2023, Katla akan menggunakan domain baru di{" "}
katla.id
. Statistik permainan anda akan dipindahkan secara otomatis.
);
}
export { getStaticProps } from "./index";
================================================
FILE: pages/index.tsx
================================================
import * as Sentry from "@sentry/nextjs";
import fs from "fs/promises";
import { GetStaticProps } from "next";
import { useRouter } from "next/router";
import path from "path";
import { ComponentProps, useEffect } from "react";
import App from "../components/App";
import Container from "../components/Container";
import Header from "../components/Header";
import HeadingWithNum from "../components/HeadingWithNum";
import HelpModal from "../components/HelpModal";
import { useModalState } from "../components/Modal";
import SettingsModal from "../components/SettingsModal";
import SponsorshipFooter from "../components/SponsorshipFooter";
import StatsModal from "../components/StatsModal";
import LocalStorage from "../utils/browser";
import { encodeHashed } from "../utils/codec";
import { GAME_STATS_KEY, LAST_HASH_KEY } from "../utils/constants";
import fetcher from "../utils/fetcher";
import { getTotalPlay, useGame, useRemainingTime } from "../utils/game";
import { handleGameComplete, handleSubmitWord } from "../utils/message";
import { trackEvent } from "../utils/tracking";
import { GameStats, MigrationData } from "../utils/types";
import createStoredState from "../utils/useStoredState";
interface Props {
hashed: string;
words: string[];
}
const initialStats: GameStats = {
distribution: {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
fail: 0,
},
currentStreak: 0,
maxStreak: 0,
};
const useStats = createStoredState(GAME_STATS_KEY);
const VALID_STATS_DELAY_MS = 5000;
export default function Home(props: Props) {
const remainingTime = useRemainingTime();
const game = useGame(props.hashed);
const [stats, setStats] = useStats(initialStats);
const [modalState, setModalState, resetModalState] = useModalState(
game,
stats
);
const router = useRouter();
useEffect(() => {
const migrationData = router.query.migrate;
if (!migrationData) {
return;
}
let data: MigrationData;
try {
data = JSON.parse(decodeURIComponent(migrationData as string));
} catch (err) {
Sentry.captureException(err, { extra: { migrationData } });
return;
}
const timeDiff = Date.now() - data.time;
if (timeDiff > VALID_STATS_DELAY_MS) {
trackEvent("invalidMigrationTime", { timeDiff });
router.replace("/");
return;
}
const hasExistingData = checkExistingData(data.stats);
let shouldContinue = true;
if (hasExistingData) {
shouldContinue = window.confirm(
`Kamu sudah memiliki statistik yang tersimpan di katla.id, apakah kamu ingin menggantinya dengan statistik terakhir dari katla.vercel.app?`
);
}
if (!shouldContinue) {
trackEvent("migrationCancelled", {
hasExistingData: hasExistingData.toString(),
});
router.replace("/");
return;
}
LocalStorage.setItem(GAME_STATS_KEY, JSON.stringify(data.stats));
LocalStorage.setItem(LAST_HASH_KEY, data.lastHash);
setStats(data.stats);
trackEvent("migrationSuccess", {});
router.replace("/");
}, [router]);
const headerProps: ComponentProps = {
customHeading: (
),
themeColor: game.state.enableHighContrast ? "#f5793a" : "#15803D",
onShowHelp: () => setModalState("help"),
onShowStats: () => setModalState("stats"),
onShowSettings: () => setModalState("settings"),
showLiarOption: game.ready && game.num === 71 && !game.state.enableLiarMode,
};
if (game.readyState === "init") {
return (
);
}
return (
setModalState("stats")}
words={props.words}
onSubmit={handleSubmitWord}
onComplete={handleGameComplete}
/>
);
}
export const getStaticProps: GetStaticProps = async () => {
const [answers, words] = await Promise.all([
fs
.readFile(path.join(process.cwd(), "./.scripts/answers.csv"), "utf8")
.then((text) =>
text
.split(",")
.map((s) => s.trim())
.filter(Boolean)
),
fetcher("https://makna.fatihkalifa.workers.dev/words.json"),
]);
return {
props: {
hashed: encodeHashed(
answers.length,
answers[answers.length - 1],
answers[answers.length - 2]
),
words: words,
},
revalidate: 60,
};
};
const checkExistingData = (newStats: GameStats) => {
if (!LocalStorage.getItem(GAME_STATS_KEY)) {
return false;
}
try {
const currentStats: GameStats = JSON.parse(
LocalStorage.getItem(GAME_STATS_KEY) as string
);
const totalPlay = getTotalPlay(currentStats);
const newTotalPlay = getTotalPlay(newStats);
if (totalPlay !== newTotalPlay) {
return true;
}
if (currentStats.maxStreak !== newStats.maxStreak) {
return true;
}
if (currentStats.currentStreak !== newStats.currentStreak) {
return true;
}
for (const v in currentStats.distribution) {
if (currentStats.distribution[v] !== newStats.distribution[v]) {
return true;
}
}
return false;
} catch (err) {
return false;
}
};
================================================
FILE: pages/lawan.tsx
================================================
import { createClient } from "@liveblocks/client";
import {
LiveblocksProvider,
RoomProvider,
useBroadcastEvent,
useOthers,
useSelf,
} from "@liveblocks/react";
import { GetStaticProps } from "next";
import {
ComponentProps,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import App from "../components/App";
import Container from "../components/Container";
import Header from "../components/Header";
import Modal, { useModalState } from "../components/Modal";
import { useTheme } from "next-themes";
import HelpModal from "../components/HelpModal";
import LiveStatsModal from "../components/LiveStatsModal";
import SettingsModal from "../components/SettingsModal";
import { decode, encode } from "../utils/codec";
import fetcher from "../utils/fetcher";
import {
defaultScore,
generateRoomId,
getEmojiFromScore,
getTotalScore,
LiveGame,
shareInviteLink,
useLiveGame,
} from "../utils/liveGame";
import { GameStats, LiveConfig } from "../utils/types";
interface Props {
words: string[];
}
export default function Lawan({ words }: Props) {
const [username, setUsername] = useState("");
const [roomId, setRoomId] = useState("");
const [isHost, setIsHost] = useState(false);
const [inviteKey, setInviteKey] = useState(null);
const client = useRef>(null);
useEffect(() => {
const query = new URLSearchParams(window.location.search);
const room = query.get("room");
const auth = query.get("auth");
const invite = query.get("invite");
if (!room && auth) {
query.set("room", generateRoomId(encode(auth)));
window.location.search = query.toString();
return;
}
if (room) {
setRoomId(room);
const [_, eauth, _id] = room.split("-");
if (auth) {
if (decode(eauth) !== auth) {
window.location.replace("/404");
return;
}
setIsHost(true);
return;
}
if (!invite) {
window.location.replace("/404");
return;
}
}
}, []);
if (!roomId) {
return {"loading..."}
;
}
function handleSubmit(e: FormEvent) {
const username = (e.target as any).name.value;
e.preventDefault();
setUsername(username);
client.current = createClient({
authEndpoint: async (room) => {
const query = new URLSearchParams(window.location.search);
const response = await fetch("/api/live", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
room,
auth: query.get("auth") ?? query.get("invite"),
username,
}),
});
// TODO: validate
const { inviteKey, ...liveblocks } = await response.json();
setInviteKey(inviteKey);
return liveblocks;
},
});
}
if (!username) {
return (
Masukkan username
Pilih
);
}
return (
);
}
export const getStaticProps: GetStaticProps = async () => {
const words = await fetcher(
"https://makna.fatihkalifa.workers.dev/words.json"
);
return {
props: {
words,
},
};
};
const initialStats: GameStats = {
distribution: {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
fail: 0,
},
currentStreak: 0,
maxStreak: 0,
};
interface MainProps {
words: string[];
config: LiveConfig;
}
function Main({ words, config }: MainProps) {
const game = useLiveGame(words);
const [stats, setStats] = useState(initialStats);
const broadcast = useBroadcastEvent();
const others = useOthers();
const self = useSelf();
const [modalState, setModalState, resetModalState] = useModalState(
game,
stats
);
const handleSendEmoji = useCallback(
(emoji: string) => {
broadcast({ type: "emoji", emoji, username: self.id });
},
[broadcast, self?.id]
);
const headerProps: ComponentProps = {
path: "/lawan",
onSendEmoji: others.count > 0 ? handleSendEmoji : undefined,
customHeading: (
Lawan
Katla
{game.ready && game.num > 0 && (
#{game.num}
)}
),
onShowHelp: () => setModalState("help"),
onShowStats: () => setModalState("stats"),
onShowSettings: () => setModalState("settings"),
isLiveMode: true,
};
const playerCount = others.toArray().length + 1;
const isReady = playerCount > 1;
useEffect(() => {
if (isReady && game.num === 0 && config.isHost) {
game.start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady, game.num]);
return (
{game.hash && isReady && (
void 0}
words={words}
/>
)}
);
}
interface GameBarProps {
game: LiveGame;
config: LiveConfig;
isReady: boolean;
}
function LiveGameBar(props: GameBarProps) {
const { game, config, isReady } = props;
const { resolvedTheme } = useTheme();
const others = useOthers();
const currentUser = useSelf();
const users = others.toArray().concat(currentUser).filter(Boolean);
const userScores = users
.map((user) => ({
id: user.id,
scores: user.presence?.scores ?? defaultScore,
isFailed: user.presence?.isFailed ?? false,
}))
.sort((a, b) => {
return getTotalScore(a.scores) > getTotalScore(b.scores) ? -1 : 1;
});
function handleShare() {
shareInviteLink(config);
}
return (
{isReady ? (
{userScores.map((entry) => (
{entry.id}
{entry.scores.map((score) =>
getEmojiFromScore(
score,
resolvedTheme === "dark",
game.state.enableHighContrast
)
)}
))}
) : (
Menunggu pemain lain terhubung...
{config.inviteKey && (
Ajak pemain
)}
)}
);
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: public/ads.txt
================================================
google.com, pub-3081263972680635, DIRECT, f08c47fec0942fa0
================================================
FILE: public/google563f40b6a67a6d75.html
================================================
google-site-verification: google563f40b6a67a6d75.html
================================================
FILE: sentry.client.config.js
================================================
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.2,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
================================================
FILE: sentry.properties
================================================
defaults.url=https://sentry.io/
defaults.org=pveyes
defaults.project=katla
cli.executable=../../.npm/_npx/26968/lib/node_modules/@sentry/wizard/node_modules/@sentry/cli/bin/sentry-cli
================================================
FILE: sentry.server.config.js
================================================
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.1,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
================================================
FILE: styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply dark:bg-gray-900 bg-white min-h-screen max-h-screen;
min-height: -webkit-fill-available;
max-height: -webkit-fill-available;
}
.absolute-center {
position: absolute;
top: 15%;
left: 50%;
transform: translateX(-50%) translateY(-15%);
z-index: 10;
}
.user-score:first-child {
margin-left: auto;
}
.user-score:last-child {
margin-right: auto;
}
:root {
--k-bg-accent: #15803d;
--k-bg-accent-hover: #16a34a;
--k-bg-correct: #15803d;
--k-bg-exist: #ca8a04;
}
:root[data-katla-hc="true"] {
--k-bg-accent: #f5793a;
--k-bg-accent-hover: #f6a248;
--k-bg-correct: #f5793a;
--k-bg-exist: #85c0f9;
}
.bg-correct {
background-color: var(--k-bg-correct);
}
.bg-exist {
background-color: var(--k-bg-exist);
}
.bg-accent {
background-color: var(--k-bg-accent);
}
.bg-ig {
background: #f09433;
background: linear-gradient(
45deg,
#f09433 0%,
#e6683c 25%,
#dc2743 50%,
#cc2366 75%,
#bc1888 100%
);
}
.color-accent {
color: var(--k-bg-accent);
}
.color-accent:hover {
color: var(--k-bg-accent-hover);
}
.text-dynamic {
font-size: clamp(0.5rem, min(3vh, 3vw), 1.5rem);
}
@media (min-height: 550px) {
.text-dynamic {
font-size: clamp(1rem, min(5vh, 5vw), 2.25rem);
}
}
@keyframes bounce {
0%,
20% {
transform: translateY(0);
}
40% {
transform: translateY(-30px);
}
50% {
transform: translateY(5px);
}
60% {
transform: translateY(-15px);
}
80% {
transform: translateY(2px);
}
100% {
transform: translateY(0);
}
}
@keyframes shake {
10%,
90% {
transform: translateX(-1px);
}
20%,
80% {
transform: translateX(2px);
}
30%,
50%,
70% {
transform: translateX(-4px);
}
40%,
60% {
transform: translateX(4px);
}
}
@keyframes flip {
0% {
transform: rotateX(0);
}
50% {
transform: rotateX(-90deg);
}
100% {
transform: rotateX(0);
}
}
@keyframes slide-down {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.slide-down[data-reach-menu-list],
.slide-down[data-reach-menu-items] {
left: -16px;
animation: slide-down 0.2s ease;
}
================================================
FILE: tailwind.config.js
================================================
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {
gridTemplateColumns: {
leaderboard: "90px 1fr",
},
},
borderWidth: {
DEFAULT: "1px",
0: "0px",
2: "2px",
3: "3px",
},
},
plugins: [],
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: utils/__tests__/answer.test.js
================================================
import { getAnswerStates } from "../game";
test("should mark `c` characters", () => {
expect(getAnswerStates("SEMUA", "BENUA")).toEqual(["w", "c", "w", "c", "c"]);
});
test("should prioritize `c` before `e` and `w` characters", () => {
expect(getAnswerStates("BABAT", "BENUA")).toEqual(["c", "e", "w", "w", "w"]);
expect(getAnswerStates("GAGAP", "GANAR")).toEqual(["c", "c", "w", "c", "w"]);
expect(getAnswerStates("NANAR", "GANAR")).toEqual(["w", "c", "c", "c", "c"]);
});
================================================
FILE: utils/__tests__/game.test.js
================================================
/**
* @jest-environment jsdom
*/
jest.useFakeTimers();
import { renderHook } from "@testing-library/react-hooks";
import { useGame } from "../game";
import { GAME_STATE_KEY, INVALID_WORDS_KEY, LAST_HASH_KEY } from "../constants";
import { decode, encode, encodeHashed } from "../codec";
import LocalStorage from "../browser";
class LocalStorageMock {
constructor() {
this.store = {};
}
clear() {
this.store = {};
}
getItem(key) {
return this.store[key] || null;
}
setItem(key, value) {
this.store[key] = String(value);
}
removeItem(key) {
delete this.store[key];
}
}
beforeAll(() => {
global.localStorage = new LocalStorageMock();
});
afterEach(() => {
localStorage.clear();
});
const num = 20;
const hashed = encodeHashed(num, "latest", "previous");
test("first time playing, ready for new game", () => {
jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
const { result } = renderHook(() => useGame(hashed));
expect(decode(result.current.hash)).toBe("latest");
expect(result.current.num).toBe(num);
expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);
});
test("first time, not ready for new game", () => {
jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());
const { result } = renderHook(() => useGame(hashed));
expect(decode(result.current.hash)).toBe("previous");
expect(result.current.num).toBe(num - 1);
expect(LocalStorage.getItem(LAST_HASH_KEY)).toBe(result.current.hash);
});
test("already played, ready for new game", () => {
const answers = ["ganar", "pakar", "syair"];
const attempt = 3;
const lastCompletedDate = Date.now();
const enableHardMode = true;
const enableHighContrast = true;
jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
localStorage.setItem(LAST_HASH_KEY, encode("previous"));
localStorage.setItem(INVALID_WORDS_KEY, JSON.stringify(["fucek"]));
localStorage.setItem(
GAME_STATE_KEY,
JSON.stringify({
answers,
attempt,
lastCompletedDate,
enableHardMode,
enableHighContrast,
})
);
const { result } = renderHook(() => useGame(hashed));
expect(decode(result.current.hash)).toBe("latest");
expect(result.current.num).toBe(20);
expect(result.current.state.answers).toEqual(Array(6).fill(""));
expect(result.current.state.attempt).toBe(0);
// keep other state
expect(result.current.state.lastCompletedDate).toBe(lastCompletedDate);
expect(result.current.state.enableHardMode).toBe(enableHardMode);
expect(result.current.state.enableHighContrast).toBe(enableHighContrast);
// reset invalid word list
expect(localStorage.getItem(INVALID_WORDS_KEY)).toBe("[]");
});
test("already played, not ready for new game", () => {
jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());
localStorage.setItem(LAST_HASH_KEY, encode("previous"));
const { result } = renderHook(() => useGame(hashed));
expect(decode(result.current.hash)).toBe("previous");
expect(result.current.num).toBe(num - 1);
});
test("already played, but new hash already generated", () => {
const answers = ["ganar", "pakar", "syair"];
const attempt = 3;
const lastCompletedDate = Date.now();
const enableHardMode = true;
const enableHighContrast = true;
jest.setSystemTime(new Date(2022, 1, 8, 22, 0, 0).getTime());
localStorage.setItem(
GAME_STATE_KEY,
JSON.stringify({
answers,
attempt,
lastCompletedDate,
enableHardMode,
enableHighContrast,
})
);
// played on date X, now on date Y
// but hashed already on date Y and Z
localStorage.setItem(LAST_HASH_KEY, encode("before"));
const { result } = renderHook(() => useGame(hashed));
expect(decode(result.current.hash)).toBe("previous");
expect(result.current.num).toBe(19);
expect(result.current.state.answers).toEqual(Array(6).fill(""));
expect(localStorage.getItem(LAST_HASH_KEY)).toBe(encode("previous"));
});
test("currently playing, should not reset state", async () => {
const answers = ["ganar", "pakar", "syair"];
const attempt = 3;
jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
localStorage.setItem(LAST_HASH_KEY, encode("latest"));
localStorage.setItem(
GAME_STATE_KEY,
JSON.stringify({
answers,
attempt,
})
);
const { result } = renderHook(() => useGame(hashed));
expect(result.current.ready).toBe(true);
expect(decode(result.current.hash)).toBe("latest");
expect(result.current.num).toBe(20);
expect(result.current.state.answers).toEqual(answers);
expect(result.current.state.attempt).toBe(attempt);
});
test("already played, refresh event", async () => {
const answers = ["ganar", "pakar", "syair"];
const attempt = 3;
jest.setSystemTime(new Date(2022, 1, 9, 0, 0, 0).getTime());
localStorage.setItem(LAST_HASH_KEY, encode("latest"));
localStorage.setItem(
GAME_STATE_KEY,
JSON.stringify({
answers,
attempt,
})
);
let currentHashed = hashed;
const { result, rerender } = renderHook(() => useGame(currentHashed));
expect(decode(result.current.hash)).toBe("latest");
expect(result.current.num).toBe(20);
// refresh
const newNum = 21;
jest.setSystemTime(new Date(2022, 1, 10, 0, 0, 0).getTime());
currentHashed = encodeHashed(newNum, "refresh", "latest");
rerender();
expect(decode(result.current.hash)).toBe("refresh");
expect(result.current.num).toBe(newNum);
});
================================================
FILE: utils/animation.ts
================================================
import showConfetti from "canvas-confetti";
export default function confetti(duration: number = 3) {
let animationFrame: any;
const end = Date.now() + duration * 1000;
function frame() {
// launch a few confetti from the left edge
showConfetti({
particleCount: 7,
angle: 60,
spread: 55,
origin: { x: 0 },
});
// and launch a few from the right edge
showConfetti({
particleCount: 7,
angle: 120,
spread: 55,
origin: { x: 1 },
});
// keep going until we are out of time
if (Date.now() < end) {
animationFrame = requestAnimationFrame(frame);
}
}
frame();
return () => cancelAnimationFrame(animationFrame);
}
================================================
FILE: utils/answers.ts
================================================
import fs from "fs/promises";
import path from "path";
export function getAllAnswers() {
return fs
.readFile(path.join(process.cwd(), "./.scripts/answers.csv"), "utf8")
.then((text) =>
text
.split(",")
.map((s) => s.trim())
.filter(Boolean)
);
}
================================================
FILE: utils/browser.ts
================================================
import Alert from "../components/Alert";
export function checkNativeShareSupport() {
const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
const isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1;
if (isFirefox && isAndroid) {
return false;
}
const isDesktop =
window.screenX === 0 &&
!("ontouchstart" in window) &&
// https://sentry.io/share/issue/5faab0d5e08a4d02a32cace759e7e3d8/
(screen?.orientation?.type ?? "landscape-primary") === "landscape-primary";
if (isDesktop) {
return false;
}
return "share" in navigator;
}
/**
* Safe & drop-in replacement for localStorage access for browser with storage disabled
* - Firefox with dom.storage.enabled = false
* - Webview with storage disabled
*/
const LocalStorage: typeof window.localStorage = {
getItem(key: string): string | null {
try {
const value = window.localStorage.getItem(key);
if (value === "undefined" || !value) {
return null;
}
return value;
} catch (err) {
return null;
}
},
setItem(key: string, value: string): void {
try {
return window.localStorage.setItem(key, value);
} catch (err) {
return;
}
},
removeItem(key: string): void {
try {
return window.localStorage.removeItem(key);
} catch (err) {
return;
}
},
clear(): void {
try {
return window.localStorage.clear();
} catch (err) {
return;
}
},
key(index: number): string | null {
try {
return window.localStorage.key(index);
} catch (err) {
return null;
}
},
get length(): number {
try {
return window.localStorage.length;
} catch (err) {
return 0;
}
},
};
export default LocalStorage;
export function isStorageEnabled() {
const randomKey = Math.random().toFixed(5);
const randomValue = Math.random().toFixed(5);
const storageKey = `katla:test:${randomKey}`;
LocalStorage.setItem(storageKey, randomValue);
const stored = LocalStorage.getItem(storageKey);
if (stored === null) {
return false;
}
LocalStorage.removeItem(storageKey);
return stored === randomValue;
}
interface ShareOptions {
cb?: () => void;
fallbackText?: string;
clipboardSuccessMessage?: string;
}
export function shareLink(url: string, options: ShareOptions) {
share({ url }, { ...options, fallbackText: url });
}
export function shareText(text: string, options: ShareOptions) {
share({ text }, { ...options, fallbackText: text });
}
export function share(data: ShareData, options: ShareOptions) {
const useNativeShare = checkNativeShareSupport();
const clipboardSuccessCallback = () => {
const message = options.clipboardSuccessMessage ?? "Disalin ke clipboard";
options.cb?.();
Alert.show(message, { id: "clipboard " });
};
const clipboardFailedCallback = (_: Error) => {
options.cb?.();
Alert.show("Gagal menyalin ke clipboard", { id: "clipboard" });
};
if (useNativeShare) {
// native share
navigator.share(data).catch(() => {
// TODO: handle non abort error
});
} else if (
"clipboard" in navigator &&
typeof navigator.clipboard.writeText === "function" &&
// https://sentry.io/share/issue/5074ad1fa6b34a2a9985edc7155967f0/
// https://stackoverflow.com/questions/61243646/clipboard-api-call-throws-notallowederror-without-invoking-onpermissionrequest
"permissions" in navigator
) {
// async clipboard API
const promise = navigator.clipboard.writeText(options.fallbackText);
// https://sentry.io/share/issue/59a42dfd516a439a99f763ee276aff26/
if (promise) {
promise.then(clipboardSuccessCallback).catch(clipboardFailedCallback);
}
} else {
// legacy browsers without async clipboard API support
const textarea = document.createElement("textarea");
textarea.textContent = options.fallbackText;
textarea.style.position = "fixed";
document.body.appendChild(textarea);
// https://sentry.io/share/issue/cb8a0ca8f6fc47858eafe4bc5959debd/
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
clipboardSuccessCallback();
} catch (err) {
clipboardFailedCallback(err);
} finally {
document.body.removeChild(textarea);
}
}
}
================================================
FILE: utils/codec.ts
================================================
export function encode(word: string): string {
const base64 = Buffer.from(word).toString("base64");
const equalSigns = base64.split("").filter((char) => char === "=").length;
const withoutEq = base64.replace(/=/g, "");
let newStr = "";
for (let i = 0; i < withoutEq.length; i++) {
newStr += String.fromCharCode(
withoutEq.charCodeAt(i) + (i % 2 === 0 ? 1 : -1)
);
}
return newStr + equalSigns;
}
export function decode(hash: string): string {
const [equalSigns, ...chars] = hash.split("").reverse();
const padding = "=".repeat(Number(equalSigns));
const base64 =
chars
.reverse()
.map((str, i) => {
const charCode = str.charCodeAt(0) + (i % 2 === 0 ? -1 : 1);
return String.fromCharCode(charCode);
})
.join("") + padding;
return Buffer.from(base64, "base64").toString();
}
const HASHED_SEPARATOR = "::";
export function encodeHashed(
num: number,
latestAnswer: string,
previousAnswer: string
) {
return encode(
[num, encode(latestAnswer), encode(previousAnswer)].join(HASHED_SEPARATOR)
);
}
export function decodeHashed(hashed: string) {
return decode(hashed).split(HASHED_SEPARATOR);
}
================================================
FILE: utils/constants.ts
================================================
export const LAST_HASH_KEY = "katla:lastHash";
export const LAST_SESSION_RESET_KEY = "katla:lastSessionReset";
export const GAME_LIVE_STATE_KEY = "katla:liveGameState";
export const GAME_STATE_KEY = "katla:gameState";
export const GAME_STATS_KEY = "katla:gameStats";
export const INVALID_WORDS_KEY = "katla:invalidWords";
// animation
export const SHAKE_ANIMATION_DURATION_MS = 600;
export const FLIP_ANIMATION_DURATION_MS = 800;
export const FLIP_ANIMATION_DELAY_MS = 400;
================================================
FILE: utils/fetcher.ts
================================================
const fetcher = (...args: Parameters) =>
fetch(...args).then((res) => res.json());
export default fetcher;
================================================
FILE: utils/formatter.ts
================================================
export function formatDate(d: Date) {
const year = d.getFullYear();
const month = d.getMonth() + 1;
const date = d.getDate();
return `${year}-${pad0(month)}-${pad0(date)}`;
}
export function formatTime(d: Date) {
const hours = d.getHours();
const minutes = d.getMinutes();
const seconds = d.getSeconds();
return `${hours}:${pad0(minutes)}:${pad0(seconds)}`;
}
export function pad0(n: number): string {
return n.toString().padStart(2, "0");
}
================================================
FILE: utils/game.ts
================================================
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { LAST_HASH_KEY, GAME_STATE_KEY, INVALID_WORDS_KEY } from "./constants";
import {
AnswerState,
Game,
GameState,
GameStats,
MigrationData,
} from "./types";
import LocalStorage, { isStorageEnabled } from "./browser";
import createStoredState from "./useStoredState";
import { trackEvent } from "./tracking";
import { decode, decodeHashed } from "./codec";
import { unstable_batchedUpdates } from "react-dom";
export const initialState: GameState = {
answers: Array(6).fill(""),
attempt: 0,
lastCompletedDate: null,
enableHighContrast: false,
enableHardMode: false,
enableFreeEdit: false,
enableLiarMode: false,
lieBoxes: [],
};
export const useGamePersistedState =
createStoredState(GAME_STATE_KEY);
export function useGame(hashed: string, enableStorage: boolean = true): Game {
const useGameState = enableStorage ? useGamePersistedState : useState;
const [state, setState] = useGameState(initialState);
const [readyState, setGameReadyState] = useState("init");
const router = useRouter();
const [num, latestHash, previousHash] = decodeHashed(hashed);
const initialCurrentNum = Number(num);
const [currentNum, setCurrentNum] = useState(initialCurrentNum);
const [currentHash, setCurrentHash] = useState(latestHash);
useEffect(() => {
if (!enableStorage) {
setGameReadyState("ready");
return;
}
if (isStorageEnabled()) {
setGameReadyState("ready");
} else {
setGameReadyState("no-storage");
return;
}
// check for new game schedule
const now = new Date();
const gameDate = new Date("2022-01-20");
gameDate.setDate(gameDate.getDate() + initialCurrentNum);
gameDate.setHours(0);
gameDate.setMinutes(0);
gameDate.setSeconds(0);
gameDate.setMilliseconds(0);
const isAfterGameDate = now.getTime() >= gameDate.getTime();
const lastHash = LocalStorage.getItem(LAST_HASH_KEY);
// first time playing
if (!lastHash) {
if (!isAfterGameDate) {
unstable_batchedUpdates(() => {
setCurrentHash(previousHash);
setCurrentNum(initialCurrentNum - 1);
});
LocalStorage.setItem(LAST_HASH_KEY, previousHash);
return;
}
LocalStorage.setItem(LAST_HASH_KEY, currentHash);
return;
}
// already play
if (lastHash !== latestHash) {
// ready for a new game
if (isAfterGameDate) {
unstable_batchedUpdates(() => {
setCurrentHash(latestHash);
setCurrentNum(initialCurrentNum);
setState((state) => ({
...state,
answers: Array(6).fill(""),
attempt: 0,
// always reset liar mode
enableLiarMode: false,
lieBoxes: [],
}));
});
LocalStorage.setItem(LAST_HASH_KEY, latestHash);
LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));
} else if (lastHash !== previousHash) {
// last hash is not in hashed
unstable_batchedUpdates(() => {
setCurrentHash(previousHash);
setCurrentNum(initialCurrentNum - 1);
setState((state) => ({
...state,
answers: Array(6).fill(""),
attempt: 0,
// always reset liar mode
enableLiarMode: false,
lieBoxes: [],
}));
});
LocalStorage.setItem(LAST_HASH_KEY, previousHash);
LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify([]));
} else {
unstable_batchedUpdates(() => {
setCurrentHash(previousHash);
setCurrentNum(initialCurrentNum - 1);
});
}
}
// we want this effect to execute only once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hashed]);
function migrate(hash: string, state: GameState) {
unstable_batchedUpdates(() => {
setCurrentHash(hash);
setState(state);
});
}
function trackInvalidWord(word: string) {
let invalidWords = [];
try {
invalidWords = JSON.parse(localStorage.getItem(INVALID_WORDS_KEY));
if (!Array.isArray(invalidWords)) {
throw new Error("invalid words is not an array");
}
} catch (err) {
invalidWords = [];
}
if (invalidWords.includes(word)) {
return;
}
invalidWords.push(word);
LocalStorage.setItem(INVALID_WORDS_KEY, JSON.stringify(invalidWords));
trackEvent("invalid_word", { word });
}
useEffect(() => {
if (state.enableHighContrast) {
document.documentElement.setAttribute("data-katla-hc", "true");
} else {
document.documentElement.removeAttribute("data-katla-hc");
}
}, [state.enableHighContrast]);
useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
router.replace(router.asPath);
}
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [router]);
return {
hash: currentHash,
num: currentNum,
migrate,
readyState,
ready: readyState !== "init",
state,
setState,
trackInvalidWord,
};
}
export function useRemainingTime() {
const now = new Date();
const hours = getHoursDiff(now);
const minutes = getMinutesDiff(now);
const seconds = getSecondsDiff(now);
const router = useRouter();
const [remainingTime, setRemainingTime] = useState({
hours,
minutes,
seconds,
});
useEffect(() => {
const t = setInterval(() => {
const now = new Date();
const hours = getHoursDiff(now);
const minutes = getMinutesDiff(now);
const seconds = getSecondsDiff(now);
if (hours + minutes + seconds === 0) {
router.replace(router.asPath);
}
setRemainingTime({ hours, minutes, seconds });
}, 100);
return () => clearInterval(t);
}, [router]);
return remainingTime;
}
function getHoursDiff(date: Date) {
return date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
? 0
: 23 - date.getHours();
}
function getMinutesDiff(date: Date) {
return date.getMinutes() === 0
? 0
: (date.getSeconds() === 0 ? 60 : 59) - date.getMinutes();
}
function getSecondsDiff(date: Date) {
return date.getSeconds() === 0 ? 0 : 60 - date.getSeconds();
}
export function isGameFinished(game: Game) {
return (
game.state.attempt === 6 ||
game.state.answers[game.state.attempt - 1] === decode(game.hash)
);
}
export function getTotalWin(stats: GameStats) {
const { fail, ...wins } = stats.distribution;
const totalWin = Object.values(wins).reduce((a, b) => a + b, 0);
return totalWin;
}
export function getTotalPlay(stats: GameStats) {
return getTotalWin(stats) + stats.distribution.fail;
}
export function verifyStreak(lastCompletedDate: number | null): boolean {
if (!lastCompletedDate) {
return true;
}
const lastDate = new Date(lastCompletedDate);
const now = new Date();
now.setDate(now.getDate() - 1);
return (
now.getDate() === lastDate.getDate() &&
now.getMonth() === lastDate.getMonth() &&
now.getFullYear() === lastDate.getFullYear()
);
}
type AnswerStates = [
AnswerState,
AnswerState,
AnswerState,
AnswerState,
AnswerState
];
export function getAnswerStates(
userAnswer: string,
answer: string
): AnswerStates {
const states: AnswerStates = Array(5).fill(null) as any;
const answerChars = answer.split("");
const userAnswerChars = userAnswer.split("");
for (let i = 0; i < answerChars.length; i++) {
if (userAnswer[i] === answerChars[i]) {
states[i] = "c";
answerChars[i] = null;
userAnswerChars[i] = null;
}
}
for (let i = 0; i < userAnswerChars.length; i++) {
if (userAnswerChars[i] === null) {
continue;
}
const answerIndex = answerChars.indexOf(userAnswer[i]);
if (answerIndex !== -1) {
states[i] = "e";
answerChars[answerIndex] = null;
userAnswerChars[i] = null;
}
}
return states.map((s) => (s === null ? "w" : s)) as any;
}
export function checkHardModeAnswer(
state: GameState,
answer: string
): [isInvalid: boolean, unusedChar: string, letterIndex?: number] {
const previousAnswer = state.answers[state.attempt - 1];
const currentAnswer = state.answers[state.attempt];
const previousAnswerStates = getAnswerStates(previousAnswer, answer);
const currentAnswerStates = getAnswerStates(currentAnswer, answer);
// first check for unused characters
const mustBeUsedChars: string[] = previousAnswerStates.flatMap((state, i) => {
if (state === "e") {
return previousAnswer[i];
}
return [];
});
for (let i = 0; i < mustBeUsedChars.length; i++) {
if (!currentAnswer.includes(mustBeUsedChars[i])) {
return [true, mustBeUsedChars[i].toUpperCase()];
}
}
// then check for matching answer
for (let i = 0; i < previousAnswerStates.length; i++) {
if (previousAnswerStates[i] === "c" && currentAnswerStates[i] !== "c") {
return [true, previousAnswer[i].toUpperCase(), i + 1];
}
}
return [false, ""];
}
export function generateMigrationLink(hash: string, stats: GameStats): string {
const migrationData: MigrationData = {
stats,
lastHash: hash,
time: Date.now(),
};
const encodedMigrationData = encodeURIComponent(
JSON.stringify(migrationData)
);
return `https://katla.id/?migrate=${encodedMigrationData}`;
}
================================================
FILE: utils/liveGame.ts
================================================
import { useEffect, useState } from "react";
import {
useBroadcastEvent,
useEventListener,
useList,
useOthers,
useRoom,
useSelf,
useUpdateMyPresence,
} from "@liveblocks/react";
import { Game, GameState, LiveConfig, LiveEvent } from "./types";
import { GAME_LIVE_STATE_KEY } from "./constants";
import createStoredState from "./useStoredState";
import LocalStorage, { isStorageEnabled, shareLink } from "./browser";
import { getAnswerStates, initialState as gameInitialState } from "./game";
import { decode, encode } from "./codec";
import { rainEmoji } from "../components/EmojiRain";
import confetti from "./animation";
import Alert from "../components/Alert";
export interface LiveGameState extends GameState {
winCount: number;
}
export interface LiveGame extends Game {
start: () => void;
}
const initialState: LiveGameState = {
...gameInitialState,
winCount: 0,
};
const useGameState = createStoredState(GAME_LIVE_STATE_KEY);
export const defaultScore = Array(5).fill(0);
const NEW_GAME_DELAY_MS = 5000;
export function useLiveGame(words: string[]): LiveGame {
const [state, setState] = useGameState(initialState);
const [readyState, setGameReadyState] = useState("init");
const hashes = useList("hashes", []);
const [num, setNum] = useState(-1);
const [hash, setHash] = useState("");
const updateMyPresence = useUpdateMyPresence();
const broadcast = useBroadcastEvent();
const self = useSelf();
const others = useOthers();
const room = useRoom();
useEffect(() => {
if (isStorageEnabled()) {
setGameReadyState("ready");
} else {
setGameReadyState("no-storage");
return;
}
}, []);
useEventListener(({ event }: { event: LiveEvent }) => {
switch (event.type) {
case "emoji": {
Alert.show(`Pesan dari: ${event.username}`, { id: "emoji" });
rainEmoji(event.emoji);
break;
}
case "win": {
handleWin(event.username);
break;
}
case "lose": {
handleLose(event.answer);
break;
}
case "start": {
handleStart();
break;
}
}
});
function submitAnswer(answer: string, attempt: number) {
const scores = getUserScores(answer, hash);
const totalScore = getTotalScore(scores);
if (totalScore === 5) {
room.batch(() => {
handleWin(self.id);
broadcast({ type: "win", username: self.id });
updateMyPresence({ winCount: (self.presence?.winCount ?? 0) + 1 });
});
setTimeout(() => {
room.batch(() => {
startNewGame();
handleStart();
});
}, NEW_GAME_DELAY_MS);
}
const isFailed = attempt === 6 && totalScore !== 5;
if (isFailed && others.toArray().every((user) => user.presence?.isFailed)) {
const answer = decode(hash);
room.batch(() => {
handleLose(answer);
broadcast({ type: "lose", answer });
});
setTimeout(() => {
room.batch(() => {
startNewGame();
handleStart();
});
}, NEW_GAME_DELAY_MS);
}
updateMyPresence({ scores, isFailed });
}
function handleWin(username: string) {
confetti();
Alert.show(
`Selamat, ${username}!!\nRonde selanjutnya akan dimulai dalam 5 detik`,
{ id: "answer", duration: NEW_GAME_DELAY_MS }
);
}
function handleLose(answer: string) {
rainEmoji("💀");
Alert.show(
`Jawaban: ${answer}.\nRonde selanjutnya akan dimulai dalam 5 detik`,
{ id: "answer", duration: NEW_GAME_DELAY_MS }
);
}
function handleStart() {
updateMyPresence({ scores: defaultScore, isFailed: false });
setState((state) => ({
...initialState,
winCount: state.winCount,
}));
}
function startNewGame() {
const unusedWords = words.filter(
(word) => !hashes.find((hash) => hash === encode(word))
);
const word = unusedWords[Math.floor(Math.random() * unusedWords.length)];
hashes.push(encode(word));
broadcast({ type: "start" });
}
function resetState() {
room.batch(() => {
hashes.clear();
startNewGame();
handleStart();
});
}
useEffect(() => {
if (!hashes) {
return;
}
function handleSubscribe() {
const hash = hashes.get(hashes.length - 1);
setNum(hashes.length);
setHash(hash);
}
handleSubscribe();
const unsubscribe = room.subscribe(hashes, handleSubscribe);
return () => unsubscribe();
}, [hashes, room]);
function start() {
room.batch(() => {
startNewGame();
handleStart();
});
}
useEffect(() => {
if (!hash) {
return;
}
try {
const storedState: LiveGameState = JSON.parse(
LocalStorage.getItem(GAME_LIVE_STATE_KEY)
);
if (storedState.attempt > 0 && !self.presence?.scores) {
const scores = getUserScores(
storedState.answers[storedState.attempt - 1],
hash
);
const totalScore = getTotalScore(scores);
const isFailed = storedState.attempt === 6 && totalScore !== 5;
updateMyPresence({ scores, isFailed, winCount: storedState.winCount });
}
} catch (_) {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
hash,
num,
migrate: () => {},
ready: readyState === "ready" && !!hashes,
readyState,
state,
setState,
trackInvalidWord: () => {},
submitAnswer,
resetState,
start,
};
}
export function generateRoomId(auth: string) {
const id = "xxxxxxxxxxxxxxxxxxxxx".replace(/[x]/g, () => {
return ((Math.random() * 16) | 0).toString(16);
});
return "kt-" + auth + "-" + id;
}
type Scores = Array;
function getUserScores(answer: string, hash: string): Scores {
return getAnswerStates(answer, decode(hash)).map((state) => {
switch (state) {
case "c":
return 1;
case "e":
return 0.5;
case "w":
return 0;
}
});
}
export function getTotalScore(scores: Scores) {
return scores.reduce((sum, state) => sum + state, 0);
}
export function getEmojiFromScore(
score: number,
darkMode: boolean,
highContrast: boolean
) {
switch (score) {
case 0:
return darkMode ? "⬜️" : "⬛";
case 1:
return highContrast ? "🟧" : "🟩";
case 0.5:
return highContrast ? "🟦" : "🟨";
}
}
export function shareInviteLink(config: LiveConfig, cb?: () => void) {
const host = window.location.protocol + "//" + window.location.host;
const url = `${host}/lawan?room=${config.roomId}&invite=${config.inviteKey}`;
shareLink(url, {
cb,
clipboardSuccessMessage: "Tautan telah disalin ke clipboard",
});
}
================================================
FILE: utils/message.ts
================================================
import { getTotalPlay } from "./game";
import { Game, AnswerState, GameStats } from "./types";
import confetti from "./animation";
import { encode } from "./codec";
import { rainEmoji } from "../components/EmojiRain";
import Alert from "../components/Alert";
export function getCongratulationMessage(attempt: number, stats: GameStats) {
const totalPlay = getTotalPlay(stats);
const { fail, ...rest } = stats.distribution;
if (totalPlay === 0 && attempt === 1) {
return randomElement(["Browser baru?", "Baru main? 👏👏"]);
}
const message1 = [
"Hoki? Atau kena spoiler",
"Wow",
"Jenius",
"Ajaib",
"Cenayang",
"Si Peramal",
"Penjelajah Waktu",
];
const message2 = [
"Kebanggaan Negara",
"Kesayangan Ibu Pertiwi",
"Sakti Mandraguna",
"Cendekiawan",
"Kaum Intelek",
];
const message3 = [
"Luar Biasa!",
"Jagoan!",
"Mantap!",
"Cerdas!",
"Keren!",
"Salut!",
"Hebat!",
"Lantip!",
"Brilian!",
"Otak Cemerlang",
];
const message4 = [
"Bagus Sekali",
"Cermat",
"Pintar",
"Teladan",
"Idaman",
"Cerdik",
"Encer",
];
const message5 = ["Bagus", "Horee", "Selamat!", "Pandai"];
const message6 = ["Nyaris!!!", "Hampir saja", "Lega!!"];
if (stats.distribution[6] + fail > totalPlay / 3) {
message6.push("Hobi amat mepet", "Suka angka 6?");
}
if (totalPlay > 7 && fail > totalPlay / 3) {
message6.push("Hampir dideportasi");
}
if (totalPlay > 7) {
message4.push("4 Sehat", "Dikit lagi Keren!");
message5.push("5 sempurna", "Tidak buruk", "Okelah");
message6.push("Mepet!");
if (stats.distribution[6] > 3) {
message6.push("Tetap semangat!", "Mungkin besok lebih baik");
}
if (stats.distribution[attempt] === 0) {
const bestMove = Object.values(rest).findIndex((value) => value > 0) + 1;
if (attempt < bestMove && stats.distribution[bestMove] > 3) {
return "Akhirnyaaa 🥳";
}
}
}
switch (attempt) {
case 1:
return randomElement(message1);
case 2:
return randomElement(message2);
case 3:
return randomElement(message3);
case 4:
return randomElement(message4);
case 5:
return randomElement(message5);
default:
return randomElement(message6);
}
}
export function getFailureMessage(
stats: GameStats,
answerStates: AnswerState[]
) {
const messageFail = ["Sayang sekali"];
if (answerStates.filter((state) => state === "c").length === 4) {
messageFail.push("Sabar ya", "Dikiit lagi 🤏", "Upss");
}
if (stats.distribution.fail > 1) {
messageFail.push(
"Jangan menyerah",
"Coba lagi besok",
"Masih belum beruntung"
);
}
if (stats.distribution.fail > 3) {
messageFail.push("Belajar lagi");
}
return randomElement(messageFail);
}
function randomElement(array: T[]): T {
const index = Math.floor(Math.random() * array.length);
return array[index];
}
const LOVE_HASHES = [
"Z1mteFF1",
"b1GybVh1",
"d1W/bVF1",
"dl:sZV51",
"bV6jZVh1",
"ZmWt[1F1",
"clmqZVh1",
"blGtbll1",
"eGWreWN1",
"dlmt[GV1",
];
const EID_HASHES = [
"cV:nc151",
"cFGnbWJ1",
"ZlG/bV51",
"[lm/dll1",
"dGWgd1F1",
"d1GrZWR1",
"flGqZWR1",
"[V2ud1l1",
"bFmrZVx1",
"bV6yZVZ1",
"d1GhZWJ1",
"eGWreWN1",
];
export function handleSubmitWord(game: Game, userAnswer: string) {
if (game.num === 25 && LOVE_HASHES.includes(encode(userAnswer))) {
const loveEmojis = ["💖", "💗", "💘", "💙", "💚", "💛", "💜", "💝"];
const emoji = loveEmojis[Math.floor(Math.random() * loveEmojis.length)];
return rainEmoji(emoji);
}
if (game.num === 102 && EID_HASHES.includes(encode(userAnswer))) {
return rainEmoji("🙏");
}
}
interface GameCompleteOptions {
hash: string;
attempt: number;
stats: GameStats;
cb?: () => void;
}
export function handleGameComplete(options: GameCompleteOptions) {
const { hash, attempt, stats, cb } = options;
const message = getCongratulationMessage(attempt, stats);
Alert.show(message, {
id: "finish",
duration: 1250,
cb,
});
if (hash === "eVy/ZVh1") {
return confetti();
}
if (hash === "ZlWxZVt1" && attempt === 1) {
return rainEmoji("💩");
}
}
export function isEidMessage(answer: string) {
return EID_HASHES.includes(encode(answer));
}
================================================
FILE: utils/tracking.ts
================================================
declare global {
interface Window {
gtag: (...args: any[]) => void;
}
}
export function trackEvent(
eventName: string,
args: Record
) {
if ("gtag" in window && typeof window.gtag === "function") {
window.gtag("event", eventName, args);
}
}
================================================
FILE: utils/types.ts
================================================
import { Dispatch, SetStateAction } from "react";
export type AnswerState = "c" | "e" | "w";
export type ForcedResult = [column: number, state: AnswerState];
export interface GameState {
answers: string[];
attempt: number;
lastCompletedDate: number | null;
enableHighContrast: boolean;
enableHardMode: boolean;
enableFreeEdit: boolean;
enableLiarMode: boolean;
lieBoxes: ForcedResult[];
}
export interface GameStats {
distribution: {
1: number;
2: number;
3: number;
4: number;
5: number;
6: number;
fail: number;
};
currentStreak: number;
maxStreak: number;
}
export interface Game {
hash: string;
num: number;
readyState: "init" | "no-storage" | "ready";
ready: boolean;
state: T;
migrate: (lastHash: string, state: T) => void;
setState: Dispatch>;
trackInvalidWord?: (word: string) => void;
submitAnswer?: (answer: string, attempt: number) => void;
resetState?: () => void;
}
export interface MigrationData {
stats: GameStats;
lastHash: string;
time: number;
}
export interface LiveConfig {
isHost: boolean;
roomId: string;
inviteKey: string;
}
interface StartEvent {
type: "start";
hash: string;
num: number;
}
interface EmojiEvent {
type: "emoji";
emoji: string;
username: string;
}
interface WinEvent {
type: "win";
username: string;
}
interface LoseEvent {
type: "lose";
answer: string;
}
export type LiveEvent = StartEvent | EmojiEvent | WinEvent | LoseEvent;
================================================
FILE: utils/useStoredState.ts
================================================
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import LocalStorage from "./browser";
export default function createStoredState(storageKey: string) {
return function useStoredState(initialState: T): [T, Dispatch>] {
const [state, setState] = useState(initialState);
function setStoredState(state: T) {
LocalStorage.setItem(storageKey, JSON.stringify(state));
setState(state);
}
useEffect(() => {
function updateStateFromStorage() {
try {
const storedState = LocalStorage.getItem(storageKey);
if (storedState) {
const parsedState = JSON.parse(storedState);
setState(parsedState);
}
} catch (_) {}
}
updateStateFromStorage();
window.addEventListener("focus", updateStateFromStorage);
return () => window.removeEventListener("focus", updateStateFromStorage);
}, []);
return [state, setStoredState];
};
}