İçeriğe geç
XDD.baby Yazılım Öğrenme
Sözlük Hata rehberi 0/56

Blok 3 · Ders 23

Formlar, kullanıcı girdisi ve localStorage

Formdan veri almak, doğrulamak, listeye eklemek ve silmek; sayfa yenilense de kaybolmasın diye tarayıcıda saklamak.

Bu dersin sonunda

  • Formun gönderilmesini submit olayıyla yakalayıp preventDefault ile sayfanın yenilenmesini durdurabilirsin
  • Girdi kutusundaki değeri okuyup Number ya da valueAsNumber ile sayıya çevirebilirsin
  • required, min, max, pattern nitelikleri ve JavaScript ile girdiyi denetleyip anlaşılır hata mesajı gösterebilirsin
  • Bir diziden (durumdan) listeyi baştan çizerek ekleme, silme, düzenleme ve filtreleme yapabilirsin
  • Veriyi localStorage'a JSON olarak kaydedip sayfa yenilenince geri yükleyebilir, ilk açılışı ve bozuk kaydı güvenle karşılayabilirsin
Bu sayfada
  1. Proje klasörü ve form
  2. Form gönderilince ne olur?
  3. Girdiyi okumak: kutudan gelen her şey metin
  4. Doğrulama: önce tarayıcı, sonra sen
  5. Veriden arayüz çizmek
  6. Silme, düzenleme ve filtreleme
  7. localStorage: sayfa kapansa da unutmasın
  8. Uygulamanın tamamı
  9. Görevler
  10. Kendini sına
  11. Kayıt noktası

Ders 22'de sayfaların tıklamalara ve tuşlara tepki verdi; bir kutuya yazılanı da harf harf okudun. Bu derste bir formu baştan sona işleyeceksin: kullanıcının yazdığını okuyacak, denetleyecek, bir listeye ekleyecek, silecek, düzenleyecek ve sayfa kapansa da kaybolmasın diye tarayıcıya kaydedeceksin.

Dersin sonunda elinde küçük ama gerçek bir uygulama olacak: maç günlüğü. Oynadığın her maçın şampiyonunu, KDA'sını, süresini ve sonucunu girersin; uygulama listeyi tutar, kazanma oranını hesaplar, tarayıcıyı kapatıp açtığında her şeyi hatırlar. Yapılacaklar listeleri, alışveriş sepetleri, yorum kutuları: web'deki formların çoğu bu dersteki kalıpla çalışır.

Proje klasörü ve form

C:\kod içinde mac-gunlugu adında yeni bir klasör oluştur ve VS Code'da File → Open Folder ile onu aç. Uygulama üç dosyadan oluşacak: iskelet index.html, görünüm stil.css, davranış app.js. Üçünü de oluştur; app.js şimdilik boş kalsın.

index.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <meta name="viewport" content="width=device-width, initial-scale=1">    <title>Maç günlüğü</title>    <link rel="stylesheet" href="stil.css">    <script src="app.js" defer></script></head><body>    <main>        <h1>Maç günlüğü</h1>         <form id="macFormu">            <label>Şampiyon                <input id="sampiyon" name="sampiyon" placeholder="Ahri">            </label>            <label>KDA (kill/ölüm/asist)                <input id="kda" name="kda" placeholder="8/2/11">            </label>            <label>Süre (dakika)                <input id="sure" name="sure" type="number" placeholder="31">            </label>            <label>Sonuç                <select id="sonuc" name="sonuc">                    <option value="zafer">Zafer</option>                    <option value="bozgun">Bozgun</option>                </select>            </label>            <p id="formHata" class="form-hata" role="alert"></p>            <div class="dugmeler">                <button id="kaydetDugmesi" type="submit">Kaydet</button>                <button id="vazgecDugmesi" class="ikincil" type="button" hidden>Vazgeç</button>            </div>        </form>         <section>            <div class="liste-bas">                <h2>Maçlar</h2>                <select id="filtre" aria-label="Hangi maçlar gösterilsin">                    <option value="hepsi">Hepsi</option>                    <option value="zafer">Zaferler</option>                    <option value="bozgun">Bozgunlar</option>                </select>            </div>            <p id="ozet"></p>            <ul id="liste"></ul>        </section>    </main></body></html>

defer sayesinde app.js sayfa okunduktan sonra çalışır ve querySelector öğeleri hazır bulur (Ders 22). Geri kalanın çoğu Ders 19'dan tanıdık. Dört ayrıntıya dikkat et:

  • Hem id hem name. id'yi JavaScript'ten öğeyi bulmak için, name'i form için yazıyoruz: tarayıcı formu gönderirken verileri bu adlarla paketler (Ders 19).
  • label'ın içinde input. Kutuyu etiketin içine koymak, for ile bağlamak kadar geçerli bir yol; etikete tıklayınca imleç kutuya gider.
  • İki düğme, iki tür. Kaydet type="submit": formu gönderir. Vazgeç type="button": kendi başına hiçbir şey yapmaz, formu da göndermez. Ona düzenleme bölümünde JavaScript ile iş vereceğiz.
  • hidden niteliği öğeyi gizler; Vazgeç şimdilik görünmüyor. #formHata, #ozet ve #liste de şimdilik boş; onları JavaScript dolduracak.

Görünüm bu dersin konusu değil; aşağıdaki CSS'in neredeyse tamamı Ders 20 ve 21'den. Yazarken grid'in ve flex'in nerede kullanıldığına bak. Yeni olan üç küçük ayrıntıyı hemen altında açıklıyoruz.

stil.css
* {    box-sizing: border-box;} body {    margin: 0;    font-family: system-ui, sans-serif;    background: #f2f3f7;    color: #1d1d2b;} main {    max-width: 640px;    margin: 0 auto;    padding: 16px;} form {    display: grid;    grid-template-columns: 1fr 1fr;    gap: 12px;    padding: 16px;    background: #ffffff;    border-radius: 12px;} label {    display: grid;    gap: 4px;    font-weight: 600;} input, select, button {    font: inherit;    padding: 8px 10px;    border: 1px solid #c4c7d4;    border-radius: 8px;} button {    background: #5b4bd6;    color: #ffffff;    border: 0;    cursor: pointer;} .form-hata {    grid-column: 1 / -1;    margin: 0;    color: #c0263b;} .dugmeler {    grid-column: 1 / -1;    display: flex;    gap: 8px;} .liste-bas {    display: flex;    justify-content: space-between;    align-items: center;} #liste {    list-style: none;    padding: 0;} #liste li {    display: flex;    gap: 8px;    align-items: center;    margin-bottom: 8px;    padding: 10px 12px;    background: #ffffff;    border-left: 6px solid #9a9db0;    border-radius: 8px;} #liste li span {    flex: 1;} #liste .zafer {    border-left-color: #1f9d55;} #liste .bozgun {    border-left-color: #d64545;} .ikincil,#liste button {    background: #e9e8f7;    color: #3a2fa3;} #liste button {    padding: 4px 10px;} @media (max-width: 480px) {    form {        grid-template-columns: 1fr;    }}
  • grid-column: 1 / -1 öğeyi grid'in ilk çizgisinden son çizgisine kadar uzatır. Hata paragrafı ve düğmeler, kaç sütun olursa olsun bütün satırı kaplar.
  • border-left yalnız sol kenara çizgi çeker; border-left-color da yalnız o çizginin rengini değiştirir. Zafer ve bozgun sınıfları kalınlığa dokunmadan rengi boyar.
  • system-ui, işletim sisteminin kendi yazı tipidir (Windows'ta Segoe UI).

Sayfayı yerel sunucuyla aç. Live Server kullanıyorsan adres 127.0.0.1:5500/index.html olur, npx serve kullanıyorsan localhost:3000. Ekranda şunu görmelisin:

127.0.0.1:5500/index.html

Form gönderilince ne olur?

Ders 22'de öğrendiğin gibi bir olay dinleyicisi ekle: form gönderilince şampiyonun adı listeye eklensin. app.js'e yaz:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const liste = document.querySelector("#liste") form.addEventListener("submit", () => {    const satir = document.createElement("li")    satir.textContent = sampiyonKutusu.value    liste.append(satir)    console.log("Listeye eklendi:", sampiyonKutusu.value)})

submit, formun gönderildiği an çalışan olaydır. Kutuları doldur (Ahri, 8/2/11, 31) ve Kaydet'e bas. Ahri satırı ya hiç görünmez ya da bir an görünüp kaybolur. Kutular boşalır, konsoldaki mesaj silinir, adres çubuğu da değişir:

Adres çubuğu
127.0.0.1:5500/index.html?sampiyon=Ahri&kda=8%2F2%2F11&sure=31&sonuc=zafer

Kodun çalıştı; sorun ondan sonra olanda. Bunu Ders 19'da görmüştün: form gönderilince tarayıcı kutuların name=değer çiftlerini adresin sonuna ekler ve sayfayı baştan yükler (/ adreste %2F diye yazılır). Bu, formun varsayılan davranışı; Ders 22'de bağlantıya tıklayınca başka sayfaya gidilmesi gibi. Yeni yüklenen sayfada ne eklediğin satır var ne de JavaScript değişkenlerin: Ders 22'deki benzetmeyle, maç baştan başladı.

Kaydet'e bas ya da Enter submit olayı kodun çalışır preventDefault yok Tarayıcı formu gönderir, adresin sonuna veri ekler: ?sampiyon=Ahri&kda=… Sayfa baştan yüklenir. olay.preventDefault() Gönderim iptal edilir. Sayfa yerinde kalır, kodun işine devam eder.
Şekil 23.1 Form gönderilince iki yol var: tarayıcı sayfayı yeniden yükler ya da sen onu durdurursun.

Bu davranış, formları sunucunun işlediği sitelerden kalma ve hâlâ geçerli: birçok arama kutusu sayfayı böyle gönderir. Bizim işimizi ise JavaScript yapacak; tarayıcıya "bu sefer gönderme" demeliyiz. Ders 22'de bağlantının varsayılan davranışını preventDefault() ile durdurmuştun; formda da aynı metot çalışır.

Dinleyici olay nesnesini Ders 22'deki gibi olay adıyla alsın ve ilk iş olarak olay.preventDefault() çağırsın:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const liste = document.querySelector("#liste") form.addEventListener("submit", (olay) => {    olay.preventDefault()    const satir = document.createElement("li")    satir.textContent = sampiyonKutusu.value    liste.append(satir)    console.log("Listeye eklendi:", sampiyonKutusu.value)})
Konsol
Listeye eklendi: Ahri

Yeniden dene: satır listede kalır, adres değişmez, mesaj konsolda durur. Şampiyon kutusundayken Enter'a basmak da formu gönderir; tarayıcı bunu Kaydet'e tıklanmış gibi işler ve yine submit çalışır.

Neden düğmenin click'i değil, formun submit'i?

Dinleyiciyi Kaydet düğmesinin click olayına da bağlayabilirdin; Enter'a basınca bile çalışırdı. Asıl fark doğrulamada. Birazdan forma "bu kutu boş geçilemez" gibi kurallar ekleyeceğiz. click bu kurallara bakılmadan önce çalışır, form bomboşken bile. submit ise yalnız bütün kurallar geçince çalışır. Veriyi submit'te almak, bozuk verinin kapıdan girmemesi demek.

Sık hataEklediğin satır bir an görünüp kayboluyor

Ne zaman çıkar? submit dinleyicisinde olay.preventDefault() yoksa. Hata mesajı çıkmaz; kutular boşalır, liste sıfırlanır ve adres çubuğuna ?sampiyon=Ahri&… gibi bir ek gelir.

Nasıl düzelir? Dinleyici olay parametresini alsın ve ilk satırı olay.preventDefault() olsun. Adres çubuğunda ? ile başlayan beklenmedik bir ek görürsen ilk şüphelin budur.

Sık hataReferenceError konsolda bir an görünüp kayboluyor

Ne zaman çıkar? preventDefault dinleyicinin sonunda dururken ondan önceki bir satır hata verirse. Hata fonksiyonu yarıda keser, preventDefault'a sıra hiç gelmez; tarayıcı formu gönderir, sayfa yenilenir ve konsol temizlenir. Preserve log açıkken gerçek mesajı görürsün:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const liste = document.querySelector("#liste") form.addEventListener("submit", (olay) => {    const satir = document.createElement("li")    satir.textContent = sampiyonKutsu.value    liste.append(satir)    olay.preventDefault()})
Konsol
Uncaught ReferenceError: sampiyonKutsu is not defined    at HTMLFormElement.<anonymous> (app.js:7:25)

Nasıl düzelir? Önce yazım hatasını düzelt: sampiyonKutusu. Sonra alışkanlık edin: olay.preventDefault() dinleyicinin ilk satırı olsun. Ondan sonra bir hata çıksa bile sayfa yerinde kalır ve mesaj konsolda durur.

Girdiyi okumak: kutudan gelen her şey metin

Şimdi bütün kutuları okuyalım. Her girdi öğesinin value özelliği içine yazılanı verir; <select>'te ise seçili seçeneğin value'sunu. Aşağıdaki ikinci console.log'da yeni bir sözcük var: typeof (okunuşu "tayp ov"), yanındaki değerin türünü metin olarak söyler. typeof 31 sonucu "number", typeof "31" sonucu "string". Bir değerin sayı mı metin mi olduğundan şüphelendiğinde ilk başvuracağın araç bu.

Listeye ekleme işini birazdan daha iyi bir yolla yazacağız; deneme için app.js'in içeriğini şununla değiştir:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const kdaKutusu = document.querySelector("#kda")const sureKutusu = document.querySelector("#sure")const sonucKutusu = document.querySelector("#sonuc") form.addEventListener("submit", (olay) => {    olay.preventDefault()    console.log(sampiyonKutusu.value, kdaKutusu.value, sureKutusu.value, sonucKutusu.value)    console.log(typeof sureKutusu.value)})
Konsol
Ahri 8/2/11 31 zaferstring

Süre kutusunun türü number olduğu hâlde typeof string diyor. Kural şu: Girdi kutusundan okunan değer her zaman metindir. type="number" kutunun neyi kabul ettiğini ve telefonda hangi klavyenin açılacağını değiştirir, value'nun türünü değiştirmez.

Bunun neden önemli olduğunu görmek için maçların toplam süresini tutmayı dene. Bu bölümdeki her denemede app.js'in içeriğini yenisiyle değiştir:

app.js
const form = document.querySelector("#macFormu")const sureKutusu = document.querySelector("#sure") let toplamSure = 0 form.addEventListener("submit", (olay) => {    olay.preventDefault()    toplamSure = toplamSure + sureKutusu.value    console.log("Toplam süre:", toplamSure)})

Süreye önce 31 yazıp Kaydet'e bas, sonra 28 yazıp yine bas:

Konsol
Toplam süre: 031Toplam süre: 03128

59 beklerken 03128 çıktı. 0 + "31" işleminde bir taraf metin olduğu için + toplamadı, yapıştırdı (Ders 1'deki metin + sayı kuralı). Sonuç artık bir metin ve her yeni süre onun sonuna eklendi.

Çözüm, değeri hesaba sokmadan önce sayıya çevirmek. İki yolun var:

  • Number(sureKutusu.value): Ders 9'daki çevirme.
  • sureKutusu.valueAsNumber: yalnız type="number" gibi sayısal kutularda çalışan, değeri doğrudan sayı olarak veren özellik.

Farkları boş kutuda ortaya çıkar. Şunu yazıp önce süre 31 iken, sonra süre kutusu boşken Kaydet'e bas:

app.js
const form = document.querySelector("#macFormu")const kdaKutusu = document.querySelector("#kda")const sureKutusu = document.querySelector("#sure") form.addEventListener("submit", (olay) => {    olay.preventDefault()    console.log(Number(sureKutusu.value), sureKutusu.valueAsNumber)    console.log(kdaKutusu.valueAsNumber)})
Konsol: önce süre 31, sonra süre boş
31 31NaN0 NaNNaN
Kutuda yazanvalueNumber(value)valueAsNumber
31 (sayı kutusu)"31"3131
hiçbir şey (sayı kutusu)""0NaN
8/2/11 (metin kutusu)"8/2/11"NaNNaN

Boş kutuyu Number sessizce 0 yapar: 0 dakikalık bir maç kaydedersin ve kimse fark etmez. valueAsNumber ise NaN verir, yani "burada sayı yok" der. Sayı kutularında valueAsNumber'ı tercih et; birazdan required ile boş kutuyu kökünden engelleyeceğiz. Metin kutusunda valueAsNumber hep NaN'dır, çünkü o kutu sayı kutusu değil.

KDA bir metin kutusu ve içinde üç sayı var. Onu Ders 9'daki gibi parçalara ayırıp her parçayı çevirirsin. Bu kısım sayfaya dokunmadığı için Node'da da deneyebilirsin:

JavaScript
const kda = "8/2/11"const parcalar = kda.split("/")console.log(parcalar)console.log(parcalar.map((parca) => Number(parca)))
Çıktı
[ '8', '2', '11' ][ 8, 2, 11 ]

Uygulamada bunu tek satırda, dizi parçalamayla (Ders 12) yazacağız: const [kill, olum, asist] = kdaKutusu.value.split("/").map((parca) => Number(parca)).

Sık hata"5" + 1 sonucu "51": girdiyi sayıya çevirmemek

Ne zaman çıkar? Kutudan okuduğun değeri çevirmeden + ile bir sayıya eklediğinde. Hata mesajı çıkmaz, sonuç sessizce yanlıştır.

app.js
const form = document.querySelector("#macFormu")const sureKutusu = document.querySelector("#sure") form.addEventListener("submit", (olay) => {    olay.preventDefault()    console.log(sureKutusu.value + 1)    console.log(Number(sureKutusu.value) + 1)})
Konsol: süre kutusunda 5 varken
516

Nasıl düzelir? Kutudan gelen değeri hesaba sokmadan önce Number(...) ya da valueAsNumber ile çevir. -, * ve / metni kendiliğinden sayıya çevirdiği için hata yalnız +'da görünür; bu da onu sinsi yapar.

Doğrulama: önce tarayıcı, sonra sen

Kullanıcı her şeyi yazabilir: kutuyu boş bırakabilir, süreye 500 yazabilir, KDA'yı 8-2-11 diye girebilir. Bozuk veri listeye girerse kazanma oranı da KDA da bozulur. Bu yüzden veriyi kabul etmeden önce doğrularız (validation). Ders 13'te fonksiyona gelen değerleri kapıdaki bir bekçiyle denetlemiştin; şimdi bekçiyi kullanıcının yazdığının önüne koyuyoruz. İlk savunma hattı HTML'in kendisi: kutulara birkaç nitelik eklemen yeter. index.html'deki üç input'u şöyle değiştir:

index.html
        <form id="macFormu">            <label>Şampiyon                <input id="sampiyon" name="sampiyon" required maxlength="20" placeholder="Ahri">            </label>            <label>KDA (kill/ölüm/asist)                <input id="kda" name="kda" required pattern="[0-9]{1,2}/[0-9]{1,2}/[0-9]{1,2}"                    placeholder="8/2/11" title="Üç sayıyı eğik çizgiyle ayır, ör. 8/2/11">            </label>            <label>Süre (dakika)                <input id="sure" name="sure" type="number" required min="1" max="120" placeholder="31">            </label>

Uzun satırı ikiye böldük; bir etiketin nitelikleri birkaç satıra yayılabilir. Kaydet'e basınca tarayıcı önce bu kuralları denetler. Kurala uymayan ilk kutunun yanında bir uyarı balonu açılır ve submit olayı hiç çalışmaz: dinleyicin bozuk veriyi görmez bile. maxlength="20" uyarı vermez, 20 karakterden fazlasını yazdırmaz.

NitelikNe ister?Türkçe Chrome'da mesajİngilizce Chrome'da mesaj
requiredKutu boş geçilemezLütfen bu alanı doldurun.Please fill out this field.
min="1"Sayı en az 1Değer 1 veya daha büyük olmalıdır.Value must be greater than or equal to 1.
max="120"Sayı en çok 120Değer 120 veya daha küçük olmalıdır.Value must be less than or equal to 120.
pattern="…"Metin bu kalıba uymalıLütfen istenen biçimi eşleştirin.Please match the requested format.

Mesajları tarayıcı yazar, tarayıcının diliyle. Chrome'un dili Türkçeyse soldaki, İngilizceyse sağdaki sütunu görürsün.

pattern'a yazılan şey bir düzenli ifade (regular expression), yani bir metin kalıbı.

Bütün dilini öğrenmen gerekmiyor; bu kalıbı okumak için üç parça yeter:

  • [0-9]: 0'dan 9'a kadar bir rakam.
  • {1,2}: hemen önündeki parça bir ya da iki kez.
  • /: eğik çizginin kendisi.

Yani [0-9]{1,2}/[0-9]{1,2}/[0-9]{1,2} "bir ya da iki basamaklı sayı, eğik çizgi, yine öyle, yine öyle" demek. Kalıp metnin tamamına uymalı: 8/2/11 ve 08/02/11 geçer; 8-2-11, 8/2, 123/1/1 ve başında boşluk olan 8/2/11 geçmez. Boş kutuya pattern bakmaz; boş kutuyu required yakalar. Kalıbı okumak zor olduğu için ne istediğini title'a insan diliyle yaz; tarayıcı bu açıklamayı uyarıyla birlikte gösterebilir.

Kaydet 1. Tarayıcı required · min max · pattern 2. Senin kodun submit içinde: trim, özel kurallar Listeye ekle Balon mesajı submit olayı hiç çalışmaz #formHata mesajı sen yazarsın, return ile durursun Yeşil ok: kural geçti · Kırmızı ok: kural bozuk
Şekil 23.2 Kaydet'e basılınca iki denetim sırayla çalışır. İkisini de geçen veri listeye girer.

Aynı denetimi JavaScript'ten yapmak

Her girdi öğesinin checkValidity() metodu, kurallara uyuyorsa true, uymuyorsa false döndürür. validationMessage özelliği de tarayıcının göstereceği mesajı verir. Formun da bir checkValidity()'si vardır: bütün kutular geçerliyse true. value'ya JavaScript'ten değer de atayabilirsin; kutunun içi değişir. Denemek için app.js'in içeriğini geçici olarak şununla değiştir ve sayfayı yenile:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const kdaKutusu = document.querySelector("#kda")const sureKutusu = document.querySelector("#sure") sureKutusu.value = "200"console.log(sureKutusu.checkValidity())console.log(sureKutusu.validationMessage) kdaKutusu.value = "8-2-11"console.log(kdaKutusu.validationMessage) sampiyonKutusu.value = "   "console.log(sampiyonKutusu.checkValidity())console.log(form.checkValidity())
Konsol (Türkçe Chrome)
falseDeğer 120 veya daha küçük olmalıdır.Lütfen istenen biçimi eşleştirin.truefalse

İngilizce Chrome'da ikinci ve üçüncü satır Value must be less than or equal to 120. ve Please match the requested format. olur. Dördüncü satıra dikkat: yalnızca üç boşluktan oluşan şampiyon adı required'ı geçti. Tarayıcı için boşluk da bir karakterdir. İşte HTML'in yakalayamadığı ilk kural; onu sen yazacaksın.

Kendi kuralın ve kendi mesajın

Tarayıcının denetimini geçen veri submit dinleyicisine gelir. Kendi kurallarını orada, preventDefault'un hemen altında yazarsın. Kural bozuksa formdaki #formHata paragrafına mesajı yaz, imleci o kutuya götür ve return ile dinleyiciden çık (erken çıkış). app.js'teki geçici denemeyi sil, yerine şunu yaz:

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const formHata = document.querySelector("#formHata") form.addEventListener("submit", (olay) => {    olay.preventDefault()     const sampiyon = sampiyonKutusu.value.trim()    if (sampiyon === "") {        formHata.textContent = "Şampiyon adı yalnız boşluktan oluşamaz."        sampiyonKutusu.focus()        return    }     formHata.textContent = ""    console.log("Geçerli:", sampiyon)})

trim() baştaki ve sondaki boşlukları atar (Ders 9); geriye bir şey kalmadıysa ad boştur. focus() imleci o kutuya götürür. #formHata'nın role="alert" niteliği, mesaj değişince ekran okuyucunun onu sesli okumasını sağlar. Şampiyon kutusuna yalnızca boşluk yaz, ötekileri doldur ve Kaydet'e bas:

127.0.0.1:5500/index.html
Maç günlüğü formu: şampiyon kutusu boş görünüyor, KDA 8/2/11, süre 31, sonuç Zafer; altında kırmızı yazıyla Şampiyon adı yalnız boşluktan oluşamaz mesajı.

Veriden arayüz çizmek

İlk denemede satırı doğrudan listeye ekledik. Ekleme için yeterli; ama silme, düzenleme ve filtreleme gelince bu yol çıkmaza girer: listedeki bir maçın KDA'sını öğrenmek için li'nin metnini ("Zafer · Ahri · 8/2/11 …") yeniden parçalaman gerekir. Sayfa, veriyi saklamak için kötü bir yerdir.

Bunun yerine bütün veriyi bir dizide tutar, ekrandaki listeyi her seferinde o diziden baştan çizeriz. Ders 22'deki dükkânda kural "önce durumu değiştir, sonra ekranı güncelle" idi; ama orada kartların kendisi de durumun bir parçasıydı, bir kartın fiyatını onun dataset'inden okuyordun. Burada bir adım ileri gidiyoruz: sayfa yalnız bir görüntü, bilgi her zaman dizide. Kural basit: veriyi değiştiren her olayın sonunda ekranı yeniden çiz.

1. Olay Kaydet, Güncelle ya da Sil 2. Durumu değiştir maclar dizisi push, filter, map 3. Kaydet localStorage'a JSON metni olarak 4. Çiz ciz() listeyi baştan kurar Ekran her zaman diziden çizilir
Şekil 23.3 Veriyi değiştiren her olay aynı döngüden geçer: durumu değiştir, kaydet, çiz. Filtre değişince yalnız 4. adım çalışır. Ekran hiçbir zaman elle düzeltilmez.

Üçüncü adımı (kaydetmeyi) dersin sonunda ekleyeceğiz. Şimdi app.js'i baştan yaz. Önce bütün öğeleri bul ve durumu kur:

app.js
const SONUC_ADI = { zafer: "Zafer", bozgun: "Bozgun" } const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const kdaKutusu = document.querySelector("#kda")const sureKutusu = document.querySelector("#sure")const sonucKutusu = document.querySelector("#sonuc")const formHata = document.querySelector("#formHata")const kaydetDugmesi = document.querySelector("#kaydetDugmesi")const vazgecDugmesi = document.querySelector("#vazgecDugmesi")const filtreKutusu = document.querySelector("#filtre")const ozet = document.querySelector("#ozet")const liste = document.querySelector("#liste") let maclar = []

SONUC_ADI, "zafer" gibi değerleri ekranda yazılacak hâllerine çevirir: SONUC_ADI[mac.sonuc] köşeli parantezle okuma (Ders 4). Adının büyük harfle yazılması Ders 17'deki DUSMANLAR gibi: "bu hiç değişmeyen bir ayar" demek. maclar'ı let ile açtık, çünkü birazdan ona yeni diziler atayacağız. Her maç bir obje olacak; alanları id, sampiyon, kill, olum, asist, sure ve sonuc.

Durumun altına, bir boş satır bırakıp iki hesap fonksiyonu yaz. kdaMetni, Ders 9'daki fonksiyonun üç sayı yerine bir maç objesi alan hâli. kazanmaOrani zaferleri filter ile sayıp yüzdeye çevirir:

app.js
function kdaMetni(mac) {    if (mac.olum === 0) {        return "Kusursuz"    }    return ((mac.kill + mac.asist) / mac.olum).toFixed(2)} function kazanmaOrani(tumMaclar) {    if (tumMaclar.length === 0) {        return "—"    }    const zaferSayisi = tumMaclar.filter((mac) => mac.sonuc === "zafer").length    return "%" + Math.round((zaferSayisi / tumMaclar.length) * 100)}

Bu iki fonksiyon hiçbir sayfa öğesine dokunmuyor; aldıklarından bir sonuç üretiyorlar (saf fonksiyon). Böyle fonksiyonları tarayıcı açmadan Node ile deneyebilirsin. Klasörde deneme.js aç, iki fonksiyonu oraya da yaz ve sınır değerleriyle çağır:

deneme.js — mac-gunlugu — Visual Studio Code
deneme.js
function kdaMetni(mac) {    if (mac.olum === 0) {        return "Kusursuz"    }    return ((mac.kill + mac.asist) / mac.olum).toFixed(2)} function kazanmaOrani(tumMaclar) {    if (tumMaclar.length === 0) {        return "—"    }    const zaferSayisi = tumMaclar.filter((mac) => mac.sonuc === "zafer").length    return "%" + Math.round((zaferSayisi / tumMaclar.length) * 100)} console.log(kdaMetni({ kill: 8, olum: 2, asist: 11 }))console.log(kdaMetni({ kill: 3, olum: 0, asist: 17 }))console.log(kazanmaOrani([]))console.log(kazanmaOrani([{ sonuc: "zafer" }, { sonuc: "bozgun" }, { sonuc: "zafer" }]))
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
PS C:\kod\mac-gunlugu> node deneme.js9.50Kusursuz—%67
Ln 1, Col 1Spaces: 4UTF-8JavaScript

Sayfaya hiç dokunmayan kodu Node'da denemek, tarayıcıda form doldurmaktan çok daha hızlıdır.

if satırları neden var? Onlar olmasaydı hiç maç yokken ve hiç ölmeyen oyuncuda ne çıkacağını Node hemen gösterir:

JavaScript
console.log("%" + Math.round((0 / 0) * 100))console.log(((3 + 17) / 0).toFixed(2))
Çıktı
%NaNInfinity

Boş liste ve sıfır ölüm: Ders 9'da da karşına çıkan sınır değerleri burada da tuzak kuruyor.

Şimdi çizim fonksiyonu. app.js'e, hesap fonksiyonlarının altına ekle:

app.js: ciz fonksiyonunun ilk hâli
function ciz() {    liste.replaceChildren()     for (const mac of maclar) {        const satir = document.createElement("li")        satir.classList.add(mac.sonuc)        satir.textContent = `${SONUC_ADI[mac.sonuc]} · ${mac.sampiyon} · ${mac.kill}/${mac.olum}/${mac.asist} · KDA ${kdaMetni(mac)} · ${mac.sure} dk`        liste.append(satir)    }     if (maclar.length === 0) {        const bos = document.createElement("li")        bos.textContent = "Henüz maç yok. İlk maçını yukarıdan ekle."        liste.append(bos)    }     const zaferSayisi = maclar.filter((mac) => mac.sonuc === "zafer").length    ozet.textContent = `${maclar.length} maç · ${zaferSayisi} zafer · kazanma oranı ${kazanmaOrani(maclar)}`}
  • liste.replaceChildren() listenin bütün çocuklarını söker; Ders 22'de dükkânı da böyle boşaltmıştın. Her çizim temiz başlar, eski satırlar iki kez görünmez.
  • classList.add(mac.sonuc) satıra zafer ya da bozgun sınıfını verir; CSS sol kenarı ona göre boyar.
  • Liste boşsa "Henüz maç yok" yazılır; boş durumu unutmak kullanıcıya bomboş bir ekran göstermek demektir. Özet de her çizimde baştan hesaplanır.

Son olarak submit dinleyicisi maçı diziye ekler ve çizer:

app.js: submit dinleyicisi ve ilk çizim
form.addEventListener("submit", (olay) => {    olay.preventDefault()     const sampiyon = sampiyonKutusu.value.trim()    if (sampiyon === "") {        formHata.textContent = "Şampiyon adı yalnız boşluktan oluşamaz."        sampiyonKutusu.focus()        return    }     const [kill, olum, asist] = kdaKutusu.value.split("/").map((parca) => Number(parca))    maclar.push({        id: Date.now(),        sampiyon: sampiyon,        kill: kill,        olum: olum,        asist: asist,        sure: sureKutusu.valueAsNumber,        sonuc: sonucKutusu.value    })     formHata.textContent = ""    form.reset()    ciz()}) ciz()

Dinleyici sayfaya tek bir satır bile eklemiyor: yalnız durumu değiştiriyor (push) ve ciz()'i çağırıyor. form.reset() formu ilk hâline döndürür, Ders 19'daki type="reset" düğmesi gibi: kutular boşalır, Sonuç yeniden Zafer olur. En alttaki ciz() sayfa açılırken bir kez çalışır. Date.now() o anın zamanını büyük bir sayı olarak verir; id: Date.now() bu sayıyı maçın kimlik numarası yapar. Ne işe yaradığını hemen göreceksin. Birkaç maç ekle ve listenin her seferinde baştan kurulduğunu izle.

Sık hataHer eklemede eski satırlar bir kez daha görünüyor

Ne zaman çıkar? ciz()'in ilk satırını, yani liste.replaceChildren()'ı unutunca. ciz her seferinde bütün diziyi yeniden yazar ama eski satırlar da yerinde kalır. Sayfayı açıp Ahri'yi, sonra Jinx'i ekleyince liste şu dört satır olur:

Ekrandaki liste
Henüz maç yok. İlk maçını yukarıdan ekle.Zafer · Ahri · 8/2/11 · KDA 9.50 · 31 dkZafer · Ahri · 8/2/11 · KDA 9.50 · 31 dkBozgun · Jinx · 4/7/9 · KDA 1.86 · 38 dk

Üstündeki özet ise doğru: 2 maç · 1 zafer · kazanma oranı %50. Veri sağlam, yalnız görüntü bozuk; çünkü özet her seferinde baştan yazılıyor, liste ise üst üste ekleniyor. Konsolda hata çıkmaz.

Nasıl düzelir? Diziden çizen fonksiyon önce eskisini söker: ciz()'in ilk satırı liste.replaceChildren() olsun.

Silme, düzenleme ve filtreleme

Silme: her maçın bir kimliği olsun

Silmek için hangi maçın silineceğini bilmen gerekir. İlk akla gelen dizideki sıra numarası; ama birazdan listeyi filtreleyeceğiz ve "Bozgunlar" seçiliyken ekrandaki ilk satır dizinin üçüncü elemanı olabilir. İki sırayı karıştırırsan yanlış maçı silersin. Bu yüzden her maça değişmeyen bir kimlik verdik. Oyunun kendisi de böyle yapar: maç geçmişindeki her maçın, liste nasıl sıralanırsa sıralansın değişmeyen bir maç kimliği vardır. Bizim kimliğimiz Date.now(): 1 Ocak 1970'ten bu yana geçen milisaniye sayısı. Elle eklenen iki maçın aynı milisaniyeye düşmesi pratikte imkânsızdır.

Satıra düğme ekleyeceğimiz için metni ayrı bir span'a koyuyoruz. ciz()'deki döngüyü şöyle değiştir:

app.js: ciz içindeki döngü
    for (const mac of maclar) {        const satir = document.createElement("li")        satir.classList.add(mac.sonuc)         const bilgi = document.createElement("span")        bilgi.textContent = `${SONUC_ADI[mac.sonuc]} · ${mac.sampiyon} · ${mac.kill}/${mac.olum}/${mac.asist} · KDA ${kdaMetni(mac)} · ${mac.sure} dk`         const silDugmesi = document.createElement("button")        silDugmesi.textContent = "Sil"        silDugmesi.addEventListener("click", () => sil(mac.id))         satir.append(bilgi, silDugmesi)        liste.append(satir)    }

Silme fonksiyonunu ciz fonksiyonunun bittiği yerin altına, submit dinleyicisinin üstüne yaz:

app.js: sil fonksiyonu
function sil(id) {    maclar = maclar.filter((mac) => mac.id !== id)    ciz()}

Her Sil düğmesinin dinleyicisi, döngünün o turundaki mac'i hatırlar (kapanış, Ders 10); bu yüzden her düğme kendi maçının kimliğini gönderir. filter, o kimlik dışındaki maçlardan yeni bir dizi kurar, biz de maclar'ı onunla değiştiririz. Sonra yine ciz(). Eski düğmeler, dinleyicileriyle birlikte replaceChildren() ile sökülür. Ders 22'deki olay yetkilendirmesiyle listeye tek dinleyici koyup kimliği dataset'ten okumak da olurdu; okuması daha kolay olduğu için burada her düğmeye kendi dinleyicisini veriyoruz.

Düzenleme: aynı form, iki iş

Düzenlemek için ayrı bir form kurmayacağız. Düzenle'ye basınca maçın bilgileri forma dolsun ve düğmenin yazısı Güncelle olsun; gönderilince yeni maç eklenmesin, var olan değişsin. Formun hangi işte olduğunu bilmek için durumu bir değişkenle genişlet, let maclar = []'ın altına yaz:

app.js: durum
let duzenlenenId = null

null "şu an düzenlenen bir maç yok" demek (null). Döngüde Sil'in önüne bir Düzenle düğmesi ekle ve append satırını güncelle:

app.js: ciz içindeki döngü, Sil'in önüne
        const duzenleDugmesi = document.createElement("button")        duzenleDugmesi.textContent = "Düzenle"        duzenleDugmesi.addEventListener("click", () => duzenlemeyeBasla(mac))
app.js: append satırının yeni hâli
        satir.append(bilgi, duzenleDugmesi, silDugmesi)

Düzenlemeyi başlatan ve formu ilk hâline döndüren iki fonksiyonu sil'in üstüne yaz:

app.js: düzenleme fonksiyonları
function duzenlemeyeBasla(mac) {    duzenlenenId = mac.id    sampiyonKutusu.value = mac.sampiyon    kdaKutusu.value = `${mac.kill}/${mac.olum}/${mac.asist}`    sureKutusu.value = mac.sure    sonucKutusu.value = mac.sonuc    kaydetDugmesi.textContent = "Güncelle"    vazgecDugmesi.hidden = false    sampiyonKutusu.focus()} function formuTemizle() {    duzenlenenId = null    form.reset()    formHata.textContent = ""    kaydetDugmesi.textContent = "Kaydet"    vazgecDugmesi.hidden = true}

hidden niteliği JavaScript'te aynı adlı bir özellik olarak durur: false yapınca Vazgeç görünür. Şimdi submit dinleyicisinde const [kill, olum, asist] satırından dinleyicinin sonuna kadar olan kısmı şununla değiştir; dosyanın en altındaki ciz() yerinde kalsın. Ekleme de güncelleme de aynı bilgileri kullandığı için onları önce bir bilgiler objesinde topluyoruz:

app.js: submit dinleyicisinin ikinci yarısı
    const [kill, olum, asist] = kdaKutusu.value.split("/").map((parca) => Number(parca))    const bilgiler = {        sampiyon: sampiyon,        kill: kill,        olum: olum,        asist: asist,        sure: sureKutusu.valueAsNumber,        sonuc: sonucKutusu.value    }     if (duzenlenenId === null) {        maclar.push({ id: Date.now(), ...bilgiler })    } else {        maclar = maclar.map((mac) => {            if (mac.id === duzenlenenId) {                return { ...mac, ...bilgiler }            }            return mac        })    }     formuTemizle()    ciz()}) vazgecDugmesi.addEventListener("click", formuTemizle)

map her maçı olduğu gibi geri verir; yalnız kimliği eşleşeni { ...mac, ...bilgiler } ile değiştirir. Yaymada (Ders 12) sonra gelen kazanır: önce eski maçın bütün alanları (kimlik dahil) kopyalanır, sonra bilgiler'deki yeni değerler onların üstüne yazılır. Vazgeç, hiçbir şeyi değiştirmeden formu temizler.

Bir sınır durumu daha: düzenlediğin maçı silersen ne olur? Form Güncelle modunda kalır, Güncelle'ye basınca map silinmiş maçı bulamaz ve yazdıkların sessizce kaybolur. sil'e bir denetim ekle:

app.js: sil fonksiyonunun yeni hâli
function sil(id) {    maclar = maclar.filter((mac) => mac.id !== id)    if (id === duzenlenenId) {        formuTemizle()    }    ciz()}

Jinx maçında Düzenle'ye basıp KDA'yı değiştirince form şöyle görünür:

127.0.0.1:5500/index.html
Düzenleme modundaki maç günlüğü: formda Jinx, KDA 5/6/12, süre 38, sonuç Bozgun; altta Güncelle ve Vazgeç düğmeleri. Listede Ahri ve Jinx maçları, Jinx'in eski KDA'sı 4/7/9 hâlâ duruyor.

Listede Jinx'in eski KDA'sı duruyor: Güncelle'ye basana kadar durum değişmedi, ekran da değişmedi.

Filtreleme: diziyi değil görünümü süz

Filtre maclar dizisini değiştirmez; yalnız neyin gösterileceğini seçer. Bu yüzden maclar'ı filtreleyip üstüne yazmayız; yazarsak gizlenen maçlar silinmiş olur.

ciz() içinde ayrı bir gorunenler dizisi kurar, döngüyü onun üzerinde döndürürüz. ciz()'in başını ve boş liste kısmını şöyle değiştir:

app.js: ciz'in başı
function ciz() {    liste.replaceChildren()    const gorunenler = maclar.filter((mac) => filtreKutusu.value === "hepsi" || mac.sonuc === filtreKutusu.value)     for (const mac of gorunenler) {
app.js: ciz'in boş liste kısmı
    if (gorunenler.length === 0) {        const bos = document.createElement("li")        if (maclar.length === 0) {            bos.textContent = "Henüz maç yok. İlk maçını yukarıdan ekle."        } else {            bos.textContent = "Bu filtreye uyan maç yok."        }        liste.append(bos)    }

Seçim değişince yeniden çiz; bu satırı dosyanın en sonundaki ciz() çağrısının üstüne ekle. change olayı, bir <select>'te seçim değiştiğinde çalışır:

app.js
filtreKutusu.addEventListener("change", ciz)

Özet satırı bilerek bütün maclar'dan hesaplanıyor: "Bozgunlar" seçiliyken kazanma oranının %0 görünmesini istemeyiz.

127.0.0.1:5500/index.html
Maçlar bölümünde filtre Bozgunlar seçili. Özet 5 maç, 3 zafer, kazanma oranı yüzde 60 diyor; listede yalnız Jinx ve Yasuo'nun bozgunları görünüyor.

localStorage: sayfa kapansa da unutmasın

Birkaç maç ekle ve F5'e bas. Liste boş; Ders 22'deki dükkânda yenileyince altının sıfırlanması gibi. maclar dizisi programın belleğinde yaşıyordu ve sayfa yenilenince program baştan başladı. Verinin kalıcı olması için onu bir yere yazmamız gerekir. Sunucu ve veritabanı Blok 4 ve 5'in konusu; şimdilik tarayıcının kendi küçük deposu yeter.

Bu derste "depo" dediğimizde bu dolabı kastediyoruz; Ders 7'deki Git deposuyla ilgisi yok. Türkçe geliştirici araçları da ona Yerel depolama der.

Üç metot

Denemeleri maç günlüğünü bozmadan yapmak için klasöre iki küçük dosya aç. depo.html yalnız betiği yükler:

depo.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <title>Depo denemeleri</title>    <script src="depo.js" defer></script></head><body>    <p>Sonuçlar konsolda.</p></body></html>

depo.js'e şunu yaz ve 127.0.0.1:5500/depo.html'i açıp konsola bak:

depo.js
localStorage.setItem("sihirdarAdi", "Ahri Main")console.log(localStorage.getItem("sihirdarAdi"))console.log(localStorage.getItem("yokBoyleBirAnahtar"))localStorage.removeItem("sihirdarAdi")console.log(localStorage.getItem("sihirdarAdi"))
Konsol
Ahri Mainnullnull
  • setItem(anahtar, değer): değeri o anahtarın altına yazar. Anahtar zaten varsa eski değerin üstüne yazar.
  • getItem(anahtar): değeri okur. Öyle bir anahtar yoksa null döndürür.
  • removeItem(anahtar): anahtarı ve değerini siler.

Sonraki denemelerde depo.js'in içeriğini her seferinde yenisiyle değiştir ve sayfayı yenile. depo.html ile maç günlüğü aynı adresten açıldığı için aynı depoyu kullanır; denemelerde maç günlüğünün kullanmayacağı anahtar adları seçtik.

Yalnız metin

Depoya bir sayı ve bir dizi koy, geri oku:

depo.js
localStorage.setItem("seviye", 18)const seviye = localStorage.getItem("seviye")console.log(seviye, typeof seviye)console.log(seviye + 1) localStorage.setItem("favoriler", ["Ahri", "Lux"])console.log(localStorage.getItem("favoriler"))
Konsol
18 string181Ahri,Lux

setItem her değeri metne çevirerek saklar; sayı koysan da metin geri gelir. Girdi kutularındaki tuzağın aynısı. Bir dizinin metin hâli de elemanlarının virgülle yan yana yazılmasıdır: köşeli parantezler ve tırnaklar gitti, elinde artık bir dizi değil "Ahri,Lux" metni var. Bir diziyi ya da objeyi saklayacaksan önce JSON metnine çevirmelisin:

depo.js
const mac = { sampiyon: "Ahri", kill: 8 }localStorage.setItem("sonMac", JSON.stringify(mac))console.log(localStorage.getItem("sonMac"))const geriGelen = JSON.parse(localStorage.getItem("sonMac"))console.log(geriGelen.sampiyon, geriGelen.kill + 1)
Konsol
{"sampiyon":"Ahri","kill":8}Ahri 9

Saklanan şey artık JSON metni; JSON.parse ondan yeniden bir obje kurdu ve kill yine sayı. Maç günlüğünde bu işi iki fonksiyon yapacak:

kaydet() yukle() maclar (dizi, bellekte) [{ sampiyon: "Ahri", … }] JSON.stringify metin '[{"sampiyon":"Ahri",…' setItem maclar (dizi) null geldiyse boş dizi: [] JSON.parse metin ya da null ilk açılışta null gelir getItem localStorage · anahtar: macGunlugu.maclar değer: aynı metin. Sayfa yenilense, tarayıcı kapansa da durur.
Şekil 23.4 Kaydederken diziyi metne çevirip yazarız, yüklerken metni okuyup yeniden diziye çeviririz.
Sık hata[object Object]: objeyi JSON.stringify olmadan saklamak

Ne zaman çıkar? setItem'a objeyi doğrudan verince. setItem onu metne çevirir; bir objenin metin hâli de [object Object]'tir (Ders 4'te metinle + yaparken görmüştün). Asıl veri kaybolmuştur; geri okumaya kalkınca JSON.parse patlar.

depo.js
const mac = { sampiyon: "Ahri", kill: 8 }localStorage.setItem("sonMac", mac)console.log(localStorage.getItem("sonMac"))const okunan = JSON.parse(localStorage.getItem("sonMac"))
Konsol
[object Object]Uncaught SyntaxError: "[object Object]" is not valid JSON    at JSON.parse (<anonymous>)    at depo.js:4:21

Nasıl düzelir? Yazarken JSON.stringify(mac), okurken JSON.parse(...). Depoda [object Object] gördüğün bir kayıt kurtarılamaz; anahtarı sil ve doğru kodla yeniden kaydet. Dizide hata mesajı bile çıkmaz: "Ahri,Lux" sessizce saklanır, sorunu ancak okurken fark edersin.

Maç günlüğüne kaydetmek ve yüklemek

Şimdi app.js'e dön. Dosyanın en üstüne bir anahtar adı ekle; let duzenlenenId = null satırının altına da bir boş satır bırakıp kaydet fonksiyonunu yaz:

app.js: en üst satır
const ANAHTAR = "macGunlugu.maclar"
app.js: kaydet fonksiyonu
function kaydet() {    localStorage.setItem(ANAHTAR, JSON.stringify(maclar))}

Anahtarın adına uygulamanın adını koyduk; nedenini birazdan göreceksin. Durumu değiştiren her yerin sonunda, ciz()'den önce kaydet() çağır: submit dinleyicisinde formuTemizle()'nin üstüne ve sil fonksiyonunda ciz()'in üstüne. Yükleme için ilk akla gelen, let maclar = [] satırını şöyle yazmak:

app.js: ilk deneme
let maclar = JSON.parse(localStorage.getItem(ANAHTAR))

Sayfayı yenile. Konsol kırmızı:

Sık hataTypeError: Cannot read properties of null (reading 'filter')

Ne zaman çıkar? Uygulama ilk kez açıldığında. O anahtarla hiç kayıt yapılmadığı için getItem null döndürür, JSON.parse(null) da hata vermeden null verir. maclar artık bir dizi değil null'dır ve onu dizi gibi kullanan ilk satır patlar.

app.js
let maclar = JSON.parse(localStorage.getItem(ANAHTAR))
Konsol
Uncaught TypeError: Cannot read properties of null (reading 'filter')    at ciz (app.js:40:31)    at app.js:142:1Uncaught TypeError: Cannot read properties of null (reading 'push')    at HTMLFormElement.<anonymous> (app.js:124:16)

İlk hata sayfa açılınca ciz()'deki filter satırında, ikincisi Kaydet'e basınca push satırında çıktı. Boş satırların bizimkinden farklıysa satır numaraları sende biraz kayar. Mesaj hatanın çıktığı yeri gösterir; sebebi 16. satırdaki okuma. Ders 13'teki kuralı hatırla: patladığı yer, sebebin olduğu yer değildir. null ile ilgili bir hatada "bu değer nereden geldi?" diye geriye doğru iz sür.

Bu hatayı görmüyorsan büyük ihtimalle arada Kaydet'e basıp bir kayıt oluşturdun; o zaman getItem metin döndürür ve sorun saklanır. Kaydı birazdan göreceğin Application panelinden silip yeniden dene.

Nasıl düzelir? Okuduğun değer null'sa boş bir diziyle başla. Doğru yukle fonksiyonu az aşağıda.

İlk açılışta kayıt olmaması normal bir durum, hata değil. Okumayı bir fonksiyona taşı ve null'ı ayrıca karşıla. 16. satırı şöyle değiştir, yukle fonksiyonunu da kaydet'in üstüne yaz:

app.js
let maclar = yukle()
app.js
function yukle() {    const kayit = localStorage.getItem(ANAHTAR)    if (kayit === null) {        return []    }    return JSON.parse(kayit)}

yukle bir fonksiyon bildirimi olduğu için tanımından önceki satırda çağrılabilir (hoisting, Ders 10). Bu hâliyle ilk açılış sorunsuz. Ama bir sorun daha var: kayıt bozulursa?

Sık hataSyntaxError: "undefined" is not valid JSON

Ne zaman çıkar? Depodaki metin JSON değilse. Mesajı Ders 12'den tanıyorsun. Sık yaşanan bir yol: kaydet'i parametreli yazıp (function kaydet(liste)) bir yerde argümanı unutmak. JSON.stringify(undefined) metin değil undefined döndürür, setItem da onu "undefined" metni olarak saklar. Bir sonraki açılışta:

app.js
function yukle() {    const kayit = localStorage.getItem(ANAHTAR)    if (kayit === null) {        return []    }    return JSON.parse(kayit)}
Konsol
Uncaught SyntaxError: "undefined" is not valid JSON    at JSON.parse (<anonymous>)    at yukle (app.js:24:17)    at app.js:16:14

Daha kötüsü, hata 16. satırdaki yukle() çağrısından yükseldiği için dosyanın geri kalanı hiç çalışmaz: submit dinleyicisi de eklenmez, Kaydet'e basınca sayfa yenilenir. Bozuk kayıt yerinde durduğu için hata her açılışta tekrar eder; kullanıcın için uygulama tamamen bozulmuştur.

Nasıl düzelir? Önce bozuk kaydı Application panelinden sil ve kaydet'i doğru çağır. Sonra yukle'yi bozuk kayda dayanıklı yap: JSON.parse'ı try/catch içine al (Ders 13).

yukle'nin son hâli:

app.js
function yukle() {    const kayit = localStorage.getItem(ANAHTAR)    if (kayit === null) {        return []    }    try {        return JSON.parse(kayit)    } catch (hata) {        console.warn("Kayıt okunamadı, boş listeyle başlıyorum:", hata.message)        return []    }}
Konsol: depoda bozuk kayıt varken
Kayıt okunamadı, boş listeyle başlıyorum: "undefined" is not valid JSON

console.warn konsola sarı bir uyarı yazar. Uygulama boş listeyle açılır ve çalışır; ilk kaydette bozuk metnin üstüne doğru veri yazılır. Bozuk veri kaybolur ama uygulama kurtulur. Depodan gelen veriye de, dışarıdan gelen her veri gibi, körü körüne güvenme.

Depoya bakmak: Application paneli

F12 → Application sekmesi (Türkçe arayüzde Uygulama) → solda Storage (Depolama) altında Local storage (Yerel depolama) → http://127.0.0.1:5500. Sekme görünmüyorsa sekme satırının sonundaki » işaretine tıkla; yer kalmayan sekmeler orada durur.

Elements Console Sources Network Application Storage ▼ Local storage http://127.0.0.1:5500 ▶ Session storage ▶ Cookies Key Value favoriler Ahri,Lux macGunlugu.maclar [{"id":17917… seviye 18 sonMac [object Object] ▼ [{id: 1791724265534, sampiyon… ▶ 0: {id: 1791724265534, samp… ▶ 1: {id: 1791724265582, samp… Türkçe arayüzde: Uygulama → Depolama → Yerel depolama, Anahtar / Değer
Şekil 23.5 Application panelinde sitenin deposu: solda adres, sağda anahtar ve değer tablosu, altta seçili değer. Geçerli bir JSON dizisi açılır bir ağaç olarak görünür.

Bu sitenin bütün anahtarları ve değerleri bir tabloda durur. Bir satırı seçince değeri alt bölmede görünür: geçerli bir JSON dizisi ya da objesiyse açılır bir ağaç olarak, Ahri,Lux ya da [object Object] gibi JSON olmayan bir metinse düz yazı olarak. Depoya doğru yazıp yazmadığını anlamanın en hızlı yolu bu.

Bir hücreye çift tıklayıp değeri düzenleyebilir, bir satırı seçip Delete ile silebilir, araç çubuğundaki düğmeyle hepsini temizleyebilirsin. Depo denemelerinden kalan seviye, favoriler ve sonMac da orada; işin bitince onları sil. Bozuk kayıt denemesi için de en hızlı araç bu: macGunlugu.maclar'ın değerini çift tıklayıp undefined yap, sayfayı yenile ve yukle'nin sarı uyarısını gör. DevTools sürümden sürüme biraz değişir; düğmelerin yeri farklıysa aradığın adlar aynıdır.

Sık hataHer maç eklenince öncekiler kayboluyor (aynı anahtarın üzerine yazmak)

Ne zaman çıkar? Her kaydı aynı anahtarla tek tek saklarsan. setItem anahtar varsa sormadan üstüne yazar; elinde hep yalnız son kayıt kalır.

app.js
const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const sonucKutusu = document.querySelector("#sonuc") form.addEventListener("submit", (olay) => {    olay.preventDefault()    const mac = { sampiyon: sampiyonKutusu.value, sonuc: sonucKutusu.value }    localStorage.setItem("mac", JSON.stringify(mac))    console.log("Kayıtlı:", localStorage.getItem("mac"))})
Konsol: Ahri, Jinx ve Lux eklendikten sonra
Kayıtlı: {"sampiyon":"Ahri","sonuc":"zafer"}Kayıtlı: {"sampiyon":"Jinx","sonuc":"bozgun"}Kayıtlı: {"sampiyon":"Lux","sonuc":"zafer"}

Application panelinde tek bir mac anahtarı ve içinde yalnız Lux görürsün.

Nasıl düzelir? Bütün maçları bir dizide tut ve diziyi tek bir anahtarla kaydet: setItem(ANAHTAR, JSON.stringify(maclar)). Her kayıtta bütün dizi yeniden yazılır, hiçbir maç kaybolmaz.

Uygulamanın tamamı

Bütün parçalar yerine oturunca app.js şöyle olmalı. Parçaları anlatılan yerlere yazdıysan dosyan bununla satırı satırına aynıdır; hata izlerindeki satır numaraları da buna göre. Kendi dosyanla karşılaştır.

Fonksiyonların sırası farklıysa da çalışır; ama ANAHTAR, let maclar = yukle() satırından önce durmalı. Sonra tanımlarsan sayfa Uncaught ReferenceError: Cannot access 'ANAHTAR' before initialization hatasıyla açılır: const ile açılan bir kutu, tanımlandığı satırdan önce kullanılamaz.

app.js
const ANAHTAR = "macGunlugu.maclar"const SONUC_ADI = { zafer: "Zafer", bozgun: "Bozgun" } const form = document.querySelector("#macFormu")const sampiyonKutusu = document.querySelector("#sampiyon")const kdaKutusu = document.querySelector("#kda")const sureKutusu = document.querySelector("#sure")const sonucKutusu = document.querySelector("#sonuc")const formHata = document.querySelector("#formHata")const kaydetDugmesi = document.querySelector("#kaydetDugmesi")const vazgecDugmesi = document.querySelector("#vazgecDugmesi")const filtreKutusu = document.querySelector("#filtre")const ozet = document.querySelector("#ozet")const liste = document.querySelector("#liste") let maclar = yukle()let duzenlenenId = null function yukle() {    const kayit = localStorage.getItem(ANAHTAR)    if (kayit === null) {        return []    }    try {        return JSON.parse(kayit)    } catch (hata) {        console.warn("Kayıt okunamadı, boş listeyle başlıyorum:", hata.message)        return []    }} function kaydet() {    localStorage.setItem(ANAHTAR, JSON.stringify(maclar))} function kdaMetni(mac) {    if (mac.olum === 0) {        return "Kusursuz"    }    return ((mac.kill + mac.asist) / mac.olum).toFixed(2)} function kazanmaOrani(tumMaclar) {    if (tumMaclar.length === 0) {        return "—"    }    const zaferSayisi = tumMaclar.filter((mac) => mac.sonuc === "zafer").length    return "%" + Math.round((zaferSayisi / tumMaclar.length) * 100)} function ciz() {    liste.replaceChildren()    const gorunenler = maclar.filter((mac) => filtreKutusu.value === "hepsi" || mac.sonuc === filtreKutusu.value)     for (const mac of gorunenler) {        const satir = document.createElement("li")        satir.classList.add(mac.sonuc)         const bilgi = document.createElement("span")        bilgi.textContent = `${SONUC_ADI[mac.sonuc]} · ${mac.sampiyon} · ${mac.kill}/${mac.olum}/${mac.asist} · KDA ${kdaMetni(mac)} · ${mac.sure} dk`         const duzenleDugmesi = document.createElement("button")        duzenleDugmesi.textContent = "Düzenle"        duzenleDugmesi.addEventListener("click", () => duzenlemeyeBasla(mac))         const silDugmesi = document.createElement("button")        silDugmesi.textContent = "Sil"        silDugmesi.addEventListener("click", () => sil(mac.id))         satir.append(bilgi, duzenleDugmesi, silDugmesi)        liste.append(satir)    }     if (gorunenler.length === 0) {        const bos = document.createElement("li")        if (maclar.length === 0) {            bos.textContent = "Henüz maç yok. İlk maçını yukarıdan ekle."        } else {            bos.textContent = "Bu filtreye uyan maç yok."        }        liste.append(bos)    }     const zaferSayisi = maclar.filter((mac) => mac.sonuc === "zafer").length    ozet.textContent = `${maclar.length} maç · ${zaferSayisi} zafer · kazanma oranı ${kazanmaOrani(maclar)}`} function duzenlemeyeBasla(mac) {    duzenlenenId = mac.id    sampiyonKutusu.value = mac.sampiyon    kdaKutusu.value = `${mac.kill}/${mac.olum}/${mac.asist}`    sureKutusu.value = mac.sure    sonucKutusu.value = mac.sonuc    kaydetDugmesi.textContent = "Güncelle"    vazgecDugmesi.hidden = false    sampiyonKutusu.focus()} function formuTemizle() {    duzenlenenId = null    form.reset()    formHata.textContent = ""    kaydetDugmesi.textContent = "Kaydet"    vazgecDugmesi.hidden = true} function sil(id) {    maclar = maclar.filter((mac) => mac.id !== id)    if (id === duzenlenenId) {        formuTemizle()    }    kaydet()    ciz()} form.addEventListener("submit", (olay) => {    olay.preventDefault()     const sampiyon = sampiyonKutusu.value.trim()    if (sampiyon === "") {        formHata.textContent = "Şampiyon adı yalnız boşluktan oluşamaz."        sampiyonKutusu.focus()        return    }     const [kill, olum, asist] = kdaKutusu.value.split("/").map((parca) => Number(parca))    const bilgiler = {        sampiyon: sampiyon,        kill: kill,        olum: olum,        asist: asist,        sure: sureKutusu.valueAsNumber,        sonuc: sonucKutusu.value    }     if (duzenlenenId === null) {        maclar.push({ id: Date.now(), ...bilgiler })    } else {        maclar = maclar.map((mac) => {            if (mac.id === duzenlenenId) {                return { ...mac, ...bilgiler }            }            return mac        })    }     kaydet()    formuTemizle()    ciz()}) vazgecDugmesi.addEventListener("click", formuTemizle)filtreKutusu.addEventListener("change", ciz) ciz()

Beş maç ekledikten sonra uygulama şöyle görünür:

127.0.0.1:5500/index.html
Maç günlüğünün tamamı: üstte form, altta Maçlar başlığı ve Hepsi filtresi, özet 5 maç, 3 zafer, kazanma oranı yüzde 60. Listede Ahri 8/2/11 KDA 9.50, Jinx 4/7/9 KDA 1.86, Lux 3/0/17 KDA Kusursuz, Garen 10/5/3 KDA 2.60, Yasuo 2/9/4 KDA 0.67; zaferlerin sol kenarı yeşil, bozgunlarınki kırmızı, her satırda Düzenle ve Sil düğmeleri.

Sayfayı yenile, tarayıcıyı kapatıp aç: maçlar yerinde. Application panelinde macGunlugu.maclar anahtarının değeri şöyle başlar:

Application → Local storageörnek, sende farklı çıkabilir
[{"id":1791724265534,"sampiyon":"Ahri","kill":8,"olum":2,"asist":11,"sure":31,"sonuc":"zafer"},{"id":1791724265582,"sampiyon":"Jinx","kill":4,"olum":7,"asist":9,"sure":38,"sonuc":"bozgun"},…

id'ler, eklediğin anın zamanı olduğu için sende farklı olur.

Görevler

Görevleri C:\kod\mac-gunlugu klasöründe yap. Şampiyon araması maç günlüğünün kendisine eklenir; ötekiler için görevde adı geçen yeni .html ve .js dosyalarını aç. Onlar 127.0.0.1:5500/altin.html gibi adreslerde açılır. Hepsi aynı kökende olduğu için anahtar adlarının başına ders23. koyuyoruz. Önce kendin dene, takılırsan ipuçlarını sırayla aç.

Görev 1

Altın kesesi

altin.html ve altin.js aç. Sayfada bir sayı kutusu ve Ekle düğmesi olsun; her gönderimde kutudaki miktar keseye eklensin ve sayfadaki yazı güncellensin. Kutu boş geçilemesin; miktar en az 1, en çok 5000 olsun. Sayfa Kesede 0 altın var. ile açılır; 300 ve 150 ekledikten sonra şunu göstermeli:

Metin
Kesede 450 altın var.

Kesede 0300150 altın var. görürsen bir şeyi çevirmeyi unuttun.

İpucu 1

Formun submit olayını dinle; ilk satır olay.preventDefault(). Kurallar için kutuya required, min ve max yaz.

İpucu 2

Toplamı dinleyicinin dışında let toplamAltin = 0 ile tut. Kutunun değerini valueAsNumber ile oku; gönderimden sonra altinFormu.reset() kutuyu boşaltır.

Çözümü göster
altin.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <title>Altın kesesi</title>    <script src="altin.js" defer></script></head><body>    <h1>Altın kesesi</h1>    <form id="altinFormu">        <label>Kazandığın altın            <input id="miktar" type="number" required min="1" max="5000">        </label>        <button type="submit">Ekle</button>    </form>    <p id="kese">Kesede 0 altın var.</p></body></html>
altin.js
const altinFormu = document.querySelector("#altinFormu")const miktarKutusu = document.querySelector("#miktar")const kese = document.querySelector("#kese") let toplamAltin = 0 altinFormu.addEventListener("submit", (olay) => {    olay.preventDefault()    toplamAltin = toplamAltin + miktarKutusu.valueAsNumber    kese.textContent = `Kesede ${toplamAltin} altın var.`    altinFormu.reset()})

Sınır denemesi: 0, -5, 5001 ve boş kutu gönderilmez; tarayıcı balonla uyarır (Türkçe Chrome'da 0 için Değer 1 veya daha büyük olmalıdır.). 5000 kabul edilir ve kese 5450 olur.

Görev 2

Sihirdar adını hatırla

sihirdar.html ve sihirdar.js aç. Bir kutuya sihirdar adını yazıp Hatırla'ya basınca ad ders23.sihirdarAdi anahtarıyla kaydedilsin ve sayfanın üstündeki yazı şöyle olsun:

Metin
Tekrar hoş geldin, Ahri Main!

Sayfa yenilenince de aynı selam görünsün. Hiç ad kaydedilmemişse Merhaba, yabancı. yazsın. Ayrı bir Unut düğmesi kaydı silip selamı Merhaba, yabancı. yapsın. Baştaki ve sondaki boşluklar kaydedilmesin; yalnız boşluktan oluşan ad kaydedilmesin, onun yerine Ad yalnız boşluktan oluşamaz. yazsın.

İpucu 1

Tek bir metin sakladığın için JSON gerekmez: setItem(ANAHTAR, ad) ve getItem(ANAHTAR) yeter.

İpucu 2

Selamı yazan bir selamla() fonksiyonu kur. Onu sayfa açılınca, kaydettikten sonra ve Unut'tan sonra çağır. getItem null döndürüyorsa kullanıcı yabancıdır.

İpucu 3

Unut için removeItem. Boşluk denetimi için adKutusu.value.trim() ve if (ad === ""), sonra return.

Çözümü göster
sihirdar.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <title>Sihirdar</title>    <script src="sihirdar.js" defer></script></head><body>    <p id="selam">Merhaba, yabancı.</p>    <form id="adFormu">        <label>Sihirdar adın            <input id="ad" required maxlength="16">        </label>        <button type="submit">Hatırla</button>    </form>    <button id="unutDugmesi" type="button">Unut</button></body></html>
sihirdar.js
const ANAHTAR = "ders23.sihirdarAdi"const selam = document.querySelector("#selam")const adFormu = document.querySelector("#adFormu")const adKutusu = document.querySelector("#ad")const unutDugmesi = document.querySelector("#unutDugmesi") function selamla() {    const ad = localStorage.getItem(ANAHTAR)    if (ad === null) {        selam.textContent = "Merhaba, yabancı."    } else {        selam.textContent = `Tekrar hoş geldin, ${ad}!`    }} adFormu.addEventListener("submit", (olay) => {    olay.preventDefault()    const ad = adKutusu.value.trim()    if (ad === "") {        selam.textContent = "Ad yalnız boşluktan oluşamaz."        return    }    localStorage.setItem(ANAHTAR, ad)    adFormu.reset()    selamla()}) unutDugmesi.addEventListener("click", () => {    localStorage.removeItem(ANAHTAR)    selamla()}) selamla()

Unut düğmesi formun dışında, yani formu zaten gönderemez; type="button" yine de iyi bir alışkanlık: düğmeyi bir gün forma taşırsan sürpriz yaşamazsın. Sınır denemesi: üç boşluk yazınca mesaj çıkar ve depoya bir şey yazılmaz (getItem hâlâ null); Ahri Main depoya Ahri Main olarak girer; Unut'tan sonra anahtar silinir ve yenileyince yine yabancısın.

Görev 3

Şampiyon araması

Maç günlüğüne bir arama kutusu ekle: yazdıkça liste yalnız şampiyon adında aranan metin geçen maçları göstersin.

  • Büyük-küçük harf fark etmesin: AHR yazınca Ahri'nin maçları çıksın.
  • Baştaki ve sondaki boşluklar yok sayılsın: lux Lux'u bulsun.
  • Arama, sonuç filtresiyle birlikte çalışsın: ahri yazıp "Bozgunlar"ı seçince yalnız Ahri'nin bozgunları görünsün.
  • Eşleşme yoksa Bu filtreye uyan maç yok. görünsün; arama kutusu boşken bütün maçlar.
İpucu 1

index.html'de <h2>Maçlar</h2>'nin altına <input id="arama" type="search" placeholder="Şampiyon ara" aria-label="Şampiyon ara"> ekle. Yazdıkça tepki vermek için input olayını dinle (Ders 22): aramaKutusu.addEventListener("input", ciz).

İpucu 2

Durumu değiştirmiyorsun; yalnız ciz()'deki gorunenler hesabını genişletiyorsun. Bir maç ancak iki koşula birden uyarsa görünür: sonuç filtresi ve ad araması.

İpucu 3

mac.sampiyon.toLowerCase().includes(aranan). aranan da önce trim(), sonra toLowerCase() görmüş olmalı. Boş metin her metnin içinde "geçer": "Ahri".includes("") true verir; arama boşken bütün maçların görünmesi bu sayede kendiliğinden olur.

Çözümü göster

index.html'de .liste-bas'ın içi:

index.html
                <h2>Maçlar</h2>                <input id="arama" type="search" placeholder="Şampiyon ara" aria-label="Şampiyon ara">                <select id="filtre" aria-label="Hangi maçlar gösterilsin">

app.js'te üç değişiklik. Öğelerin arasına:

app.js: filtreKutusu'nun altına
const aramaKutusu = document.querySelector("#arama")

ciz()'deki gorunenler satırının yerine:

app.js: ciz içinde
    const aranan = aramaKutusu.value.trim().toLowerCase()    const gorunenler = maclar.filter((mac) => {        const sonucUyuyor = filtreKutusu.value === "hepsi" || mac.sonuc === filtreKutusu.value        const adUyuyor = mac.sampiyon.toLowerCase().includes(aranan)        return sonucUyuyor && adUyuyor    })

Dosyanın sonuna, filtreKutusu'nun dinleyicisinin altına:

app.js
aramaKutusu.addEventListener("input", ciz)

Sınır denemesi (Ahri, Jinx, Lux, Irelia ve ikinci bir Ahri maçıyla): boş arama beş maçı da gösterir; AHR iki Ahri maçını; lux Lux'u; irelia ve IRELIA Irelia'yı; zzz "Bu filtreye uyan maç yok." mesajını. ahri + "Bozgunlar" yalnız Ahri'nin bozgununu gösterir, özet ise hep beş maçı sayar.

Neden Ders 9'daki toLocaleLowerCase("tr") değil? Şampiyon adları İngilizce. Türkçe kuralla "Irelia".toLocaleLowerCase("tr") sonucu "ırelia" olur ve irelia araması onu bulamaz. Hangi harf kuralını kullanacağın, metnin hangi dilde olduğuna bağlı.

Görev 4

Riot ID kutusu

riot.html ve riot.js aç. Riot ID'nin biçimi ad#etiket: ad 3–16 harf, rakam ya da boşluk; etiket 3–5 harf ya da rakam (Ahri#TR1, Ahri Main#TR1). Riot'un gerçek kuralları daha ayrıntılı; görevde sade bir hâlini uyguluyoruz.

  • Kural HTML'de dursun: required, pattern, maxlength.
  • Kullanıcı yazdıkça kutunun altında senin Türkçe mesajın görünsün: biçim doğruysa Biçim doğru., değilse ne beklendiğini anlatan bir cümle, kutu boşsa hiçbir şey.
  • Gönderilince Kaydedildi: Ahri#TR1 yazsın; baştaki ve sondaki boşluklar atılsın.

Ah#TR1, Ahri#TR, AhriTR1 ve 17 harfli bir ad geçersiz; Ahri#TR1, Ahri Main#TR1 ve 16 harfli bir ad geçerli olmalı.

İpucu 1

Kalıp: [A-Za-z0-9 ]{3,16}#[A-Za-z0-9]{3,5}. Köşeli parantezin içindeki boşluk "boşluk da olur" demek. En uzun Riot ID 16 + 1 + 5 = 22 karakter: maxlength="22".

İpucu 2

Canlı mesaj için kutunun input olayını dinle ve riotKutusu.checkValidity()'ye bak. Önce kutunun boş olup olmadığını denetle; boş kutu da required yüzünden geçersiz sayılır ama o anda hata mesajı göstermek kullanıcıyı boşuna azarlamak olur.

Çözümü göster
riot.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <title>Riot ID</title>    <script src="riot.js" defer></script></head><body>    <form id="riotFormu">        <label>Riot ID            <input id="riotId" required maxlength="22" pattern="[A-Za-z0-9 ]{3,16}#[A-Za-z0-9]{3,5}"                placeholder="Ahri#TR1" title="Ad, # ve etiket. Ör. Ahri#TR1">        </label>        <button type="submit">Kaydet</button>    </form>    <p id="durum"></p></body></html>
riot.js
const riotFormu = document.querySelector("#riotFormu")const riotKutusu = document.querySelector("#riotId")const durum = document.querySelector("#durum") riotKutusu.addEventListener("input", () => {    if (riotKutusu.value === "") {        durum.textContent = ""    } else if (riotKutusu.checkValidity()) {        durum.textContent = "Biçim doğru."    } else {        durum.textContent = "Biçim: ad#etiket. Ad 3–16, etiket 3–5 harf ya da rakam (ör. Ahri#TR1)."    }}) riotFormu.addEventListener("submit", (olay) => {    olay.preventDefault()    durum.textContent = `Kaydedildi: ${riotKutusu.value.trim()}`    riotFormu.reset()})

Sınır denemesi: Ahri#TR123 (5 karakterlik etiket) ve Abcdefghijklmnop#TR1 (16 harf) geçerli; Ahri#TR1234, Ahri##TR1 ve Abcdefghijklmnopq#TR1 (17 harf) geçersiz. Ağrı#TR1 de geçersiz: [A-Za-z] yalnız İngilizce harfleri kapsar. Ahri#TR1 (başta boşluk) kalıba uyar, çünkü adda boşluk serbest; trim() onu Kaydedildi: Ahri#TR1'e çevirir. Ahri#TR1 (sonda boşluk) uymaz, çünkü etikette boşluk yok. Geçersiz ID'yle Kaydet'e basınca tarayıcının balonu çıkar (Lütfen istenen biçimi eşleştirin.) ve submit çalışmaz.

Görev 5

Favori şampiyonlar

favori.html ve favori.js ile bir favori şampiyon listesi kur:

  • Formdan ad eklenir. Liste ders23.favoriler anahtarıyla JSON olarak saklanır; sayfa yenilense de kalır.
  • Her satırda Yıldızla ve Sil düğmeleri var. Yıldızla, adın başına ★ koyar ve düğmenin yazısını Yıldızı kaldır yapar; tekrar basınca geri alır.
  • Aynı ad, büyük-küçük harf farkı gözetmeden ikinci kez eklenemez: Ahri listedeyken ahri yazınca ahri zaten listede. mesajı çıkar.
  • Yalnız boşluktan oluşan ad eklenmez. Liste boşken Henüz favorin yok. yazar.
İpucu 1

Maç günlüğünün iskeletini izle: favoriler dizisi (durum), yukle(), kaydet(), ciz(). Her favori bir obje: { ad: "Lux", yildizli: false }.

İpucu 2

Adlar tekrarsız olacağı için kimlik yerine adı kullanabilirsin. Var mı denetimi için some (Ders 11): favoriler.some((f) => f.ad.toLowerCase() === ad.toLowerCase()).

İpucu 3

Yıldızlamak bir düzenleme: map ile o favorinin kopyasını { ...f, yildizli: !f.yildizli } olarak kur. ! doğruyu yanlışa, yanlışı doğruya çevirir. Her değişiklikten sonra kaydet() ve ciz().

Çözümü göster
favori.html
<!doctype html><html lang="tr"><head>    <meta charset="utf-8">    <title>Favori şampiyonlar</title>    <script src="favori.js" defer></script></head><body>    <h1>Favori şampiyonlar</h1>    <form id="favoriFormu">        <label>Şampiyon            <input id="favoriAd" required maxlength="20">        </label>        <button type="submit">Ekle</button>    </form>    <p id="favoriHata"></p>    <ul id="favoriListe"></ul></body></html>
favori.js
const ANAHTAR = "ders23.favoriler"const favoriFormu = document.querySelector("#favoriFormu")const adKutusu = document.querySelector("#favoriAd")const favoriHata = document.querySelector("#favoriHata")const favoriListe = document.querySelector("#favoriListe") let favoriler = yukle() function yukle() {    const kayit = localStorage.getItem(ANAHTAR)    if (kayit === null) {        return []    }    try {        return JSON.parse(kayit)    } catch (hata) {        return []    }} function kaydet() {    localStorage.setItem(ANAHTAR, JSON.stringify(favoriler))} function ciz() {    favoriListe.replaceChildren()    if (favoriler.length === 0) {        const bos = document.createElement("li")        bos.textContent = "Henüz favorin yok."        favoriListe.append(bos)        return    }    for (const favori of favoriler) {        const satir = document.createElement("li")        const ad = document.createElement("span")        const yildizDugmesi = document.createElement("button")        if (favori.yildizli) {            ad.textContent = `★ ${favori.ad}`            yildizDugmesi.textContent = "Yıldızı kaldır"        } else {            ad.textContent = favori.ad            yildizDugmesi.textContent = "Yıldızla"        }        yildizDugmesi.addEventListener("click", () => {            favoriler = favoriler.map((f) => {                if (f.ad === favori.ad) {                    return { ...f, yildizli: !f.yildizli }                }                return f            })            kaydet()            ciz()        })        const silDugmesi = document.createElement("button")        silDugmesi.textContent = "Sil"        silDugmesi.addEventListener("click", () => {            favoriler = favoriler.filter((f) => f.ad !== favori.ad)            kaydet()            ciz()        })        satir.append(ad, " ", yildizDugmesi, " ", silDugmesi)        favoriListe.append(satir)    }} favoriFormu.addEventListener("submit", (olay) => {    olay.preventDefault()    favoriHata.textContent = ""    const ad = adKutusu.value.trim()    if (ad === "") {        favoriHata.textContent = "Ad yalnız boşluktan oluşamaz."        return    }    const zatenVar = favoriler.some((f) => f.ad.toLowerCase() === ad.toLowerCase())    if (zatenVar) {        favoriHata.textContent = `${ad} zaten listede.`        return    }    favoriler.push({ ad: ad, yildizli: false })    kaydet()    favoriFormu.reset()    ciz()}) ciz()

append'e metin de verebilirsin: " " düğmelerin arasına boşluk koyar. Ahri, Lux ve Jinx'i ekleyip ahri'yi bir daha eklemeye çalıştıktan ve Lux'u yıldızladıktan sonra sayfa şöyle görünür (CSS yazmadığımız için tarayıcının varsayılan görünümü):

Stilsiz bir sayfa: Favori şampiyonlar başlığı, kutuda ahri yazıyor, altında ahri zaten listede mesajı; listede Ahri Yıldızla Sil, yıldızlı Lux Yıldızı kaldır Sil, Jinx Yıldızla Sil.

Sınır denemesi: ahri ve LUX "zaten listede" der; üç boşluk eklenmez; jinx boşlukları atılarak eklenir. Yenileyince yıldız yerinde durur; depoda [{"ad":"Ahri","yildizli":false},{"ad":"Lux","yildizli":true},{"ad":"jinx","yildizli":false}] gibi bir değer olur. Hepsini silince depoda [] kalır ve yenileyince yine Henüz favorin yok. görünür.

Kendini sına

Soru 1

Süre kutusunda 7 yazıyor. Bu satır ne yazar?

JavaScript
console.log(sureKutusu.value * 2, sureKutusu.value + 2)
Cevabı göster

14 72. value metindir: "7". * metni kendiliğinden sayıya çevirir, "7" * 2 sonucu 14 olur. + ise bir taraf metin olunca yapıştırır: "72". Kutudan gelen değeri her zaman önce çevir.

Soru 2

Bu satırlar ne yazar?

JavaScript
localStorage.setItem("altin", 300)const altin = localStorage.getItem("altin")console.log(altin + 50, altin - 50)
Cevabı göster

30050 250. setItem sayıyı metin olarak sakladı, getItem de "300" metnini döndürdü. "300" + 50 yapıştırır, "300" - 50 hesaplar. Doğrusu Number(localStorage.getItem("altin")) ya da veriyi JSON olarak saklamak.

Soru 3

hicYok anahtarıyla hiç kayıt yapılmadı. Hangi satır hata verir, mesajı ne olur?

JavaScript
const kayit = JSON.parse(localStorage.getItem("hicYok"))console.log(kayit.length)
Cevabı göster

İlk satır hata vermez: getItem null döndürür, JSON.parse(null) da null verir. İkinci satır TypeError: Cannot read properties of null (reading 'length') ile patlar. Hatanın çıktığı yer 2. satır, sebebi 1. satırdaki karşılanmamış null. Çözüm yukle'deki gibi null'ı ayrıca karşılamak.

Soru 4

Dinleyiciyi neden formun submit olayına bağlıyoruz da Kaydet düğmesinin click olayına bağlamıyoruz?

Cevabı göster

Çünkü submit yalnız required, min, max, pattern gibi kurallar geçince çalışır; click ise doğrulamadan önce, form bomboşken bile çalışır. Enter'a basmak iki olayı da tetikler; asıl fark doğrulamada.

Soru 5

required olan şampiyon kutusuna yalnız üç boşluk yazıp (öteki kutular doğru) Kaydet'e basarsan submit olayı çalışır mı?

Cevabı göster

Çalışır. Tarayıcı için boşluk da bir karakterdir; required yalnız tamamen boş kutuyu durdurur (checkValidity() bu durumda true verir). Bu kuralı kendin yazarsın: value.trim() boşsa mesaj göster ve return ile çık.

Soru 6

Filtre "Bozgunlar"dayken ekrandaki ilk satırın Sil düğmesine basıldı. Silmeyi ekrandaki sıraya göre yapsaydık (ekranın ilk satırı = dizinin ilk elemanı) ne olurdu?

Cevabı göster

Yanlış maç silinirdi. Filtre zaferleri gizlediği için ekranın ilk satırı dizinin ilk elemanı olmayabilir; dizinin başında bir zafer duruyorsa o silinirdi. Bu yüzden her maçın ekranın düzeninden bağımsız, değişmeyen bir id'si var ve silme filter((mac) => mac.id !== id) ile yapılıyor.

Soru 7

Dün 127.0.0.1:5500/index.html'den açıp maç eklemiştin. Bugün localhost:5500/index.html'den açtın ve liste boş. Maçların silindi mi?

Cevabı göster

Hayır. localStorage kökene bağlıdır ve 127.0.0.1:5500 ile localhost:5500 iki ayrı köken sayılır. Sayfayı 127.0.0.1:5500'den aç, maçların orada.

Kayıt noktası

Bu derste yeni bir proje klasörü açtın; Git'in onu tanıması için orada bir kez git init gerekiyor.

Bir sonraki derste veriyi kendin girmek yerine internetten çekeceksin: fetch ile bir API'den bütün şampiyon listesini alıp sayfada kartlara dökecek, yüklenme ve hata durumlarını göstereceksin.

İşaret yalnızca bu tarayıcıda saklanır, kimseye gönderilmez.