@studiotitta/studio-editori 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,6 +102,14 @@ export interface TekstilistaKentta<K extends boolean = boolean> extends KentanPe
102
102
  /** Monirivinen kohta listassa. */
103
103
  monirivinen?: boolean;
104
104
  enintaan?: number;
105
+ /**
106
+ * Miten lista näkyy sivulla yhtenä tekstinä, esimerkiksi ", ".
107
+ *
108
+ * Kun sivusto näyttää listan pilkuilla eroteltuna ("Lounas, Materiaalit"),
109
+ * sen voi silti muokata paikallaan: editori jakaa tekstin takaisin listaksi
110
+ * tällä erottimella. Ilman tätä jokainen rivi on oma kohtansa.
111
+ */
112
+ erotin?: string;
105
113
  }
106
114
  /**
107
115
  * Kenttäryhmä, esimerkiksi hakukonetiedot.
@@ -134,6 +142,14 @@ export interface KohdelistaKentta extends KentanPerus {
134
142
  * puhuttaisiin kohteista, mikä ei ole kenenkään sana omasta työstään.
135
143
  */
136
144
  yksikko?: string;
145
+ /**
146
+ * Kohteen oma osoite sivustolla, esimerkiksi "/kurssit/{tunnus}".
147
+ *
148
+ * Editori näyttää linkin, jolla kohteen sivun pääsee katsomaan. Uuden
149
+ * kohteen sivu syntyy vasta tallennuksen jälkeen, joten linkki kertoo
150
+ * myös, mistä työn tuloksen näkee.
151
+ */
152
+ osoite?: string;
137
153
  kentat: Record<string, Kentta>;
138
154
  }
139
155
  export type Kentta = TekstiKentta | TekstialueKentta | LukuKentta | ValintaKentta | KytkinKentta | AikaKentta | KuvaKentta | KielivalintaKentta | TekstilistaKentta | RyhmaKentta | KohdelistaKentta;
@@ -125,6 +125,15 @@ export function kuvienReitti(yliajot) {
125
125
  console.error("[editori] Kuvan tallennus epäonnistui:", virhe);
126
126
  return json({ virhe: "Kuvaa ei saatu tallennettua." }, 502);
127
127
  }
128
- return json({ src: kuvanOsoite(polku), ehdotus: await ehdotus });
128
+ /*
129
+ Esikatselu palautetaan kuvana, ei osoitteena: tiedosto on repossa, mutta
130
+ sivusto tarjoilee sen vasta seuraavan julkaisun jälkeen. Ilman tätä
131
+ muokkaaja näkee rikkinäisen kuvan eikä tiedä, menikö lataus läpi.
132
+ */
133
+ return json({
134
+ src: kuvanOsoite(polku),
135
+ esikatselu: `data:image/${tunnistaTyyppi(valmis) ?? "jpeg"};base64,${Buffer.from(valmis).toString("base64")}`,
136
+ ehdotus: await ehdotus,
137
+ });
129
138
  };
130
139
  }
@@ -1,3 +1,3 @@
1
- declare const PALKIN_TYYLIT = "\n :host { all: initial; }\n .palkki {\n position: fixed; inset: auto 1rem 1rem auto; z-index: 2147483000;\n display: flex; align-items: center; gap: .75rem;\n padding: .6rem .75rem .6rem 1rem; border-radius: .5rem;\n background: #1d1f1e; color: #fff;\n font: 500 .875rem/1.4 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: 0 .5rem 1.5rem rgba(0,0,0,.25);\n max-width: calc(100vw - 2rem);\n }\n .tila { opacity: .75; font-weight: 400; }\n .tila[data-virhe=\"1\"] { color: #ffb4a2; opacity: 1; }\n button {\n font: inherit; padding: .45rem .8rem; border-radius: .35rem;\n border: 1px solid rgba(255,255,255,.25); background: transparent; color: inherit;\n cursor: pointer;\n }\n button.ensisijainen { background: #fff; color: #1d1f1e; border-color: #fff; }\n button:disabled { opacity: .5; cursor: default; }\n button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n /* Sivupaneeli */\n .paneeli {\n position: fixed; inset: 0 0 0 auto; z-index: 2147482999;\n width: min(23rem, 100vw); overflow-y: auto;\n padding: 1.25rem 1.25rem 6rem;\n background: #fbfaf8; color: #1d1f1e;\n border-left: 1px solid rgba(0,0,0,.12);\n font: 400 .875rem/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: -.5rem 0 1.5rem rgba(0,0,0,.08);\n }\n .paneeli h2 { margin: 0 0 .75rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .06em; opacity: .6; }\n .paneeli section + section { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(0,0,0,.1); }\n .kentta { margin-bottom: 1rem; }\n .kentta label { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .25rem; font-weight: 600; }\n .kieli { font-weight: 400; font-size: .75rem; padding: 0 .3rem; border-radius: .2rem; background: rgba(0,0,0,.08); text-transform: uppercase; }\n .kentta input[type=text], .kentta input[type=number], .kentta input[type=date],\n .kentta input[type=datetime-local], .kentta select, .kentta textarea {\n width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25);\n border-radius: .25rem; background: #fff; font: inherit; color: inherit;\n }\n .kentta textarea { resize: vertical; }\n .kytkin { display: flex; gap: .5rem; align-items: center; font-weight: 400; }\n .kielivalinta { display: grid; gap: .35rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .ohje { margin: .25rem 0 0; font-size: .8125rem; opacity: .65; }\n .laskuri { margin: .3rem 0 0; font-size: .8125rem; font-weight: 600; }\n .virheet { padding: .75rem .9rem; border-radius: .3rem; background: #f6e8e3; color: #6d2b20; }\n .virheet h2 { margin: 0 0 .4rem; opacity: 1; color: inherit; }\n .virheet ul { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n .kuva img { display: block; width: 100%; height: auto; border-radius: .25rem; margin-bottom: .4rem; }\n .kuva input[type=text] { width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25); border-radius: .25rem; font: inherit; }\n /* Kuvan tekstikentt\u00E4 on oma lohkonsa: label on .kentta-tasolla flex,\n ja ilman t\u00E4t\u00E4 otsikko ja kentt\u00E4 asettuisivat vierekk\u00E4in. */\n .kentta label.alt, .kuva label.alt { display: block; font-weight: 600; margin-top: .5rem; }\n .alt input { margin-top: .25rem; width: 100%; }\n .huomio {\n margin: .4rem 0 0; padding: .5rem .6rem; border-radius: .25rem;\n background: #fdf3e7; color: #6b4a1f; font-size: .8125rem;\n }\n .huomio p { margin: 0 0 .4rem; }\n .huomio button { color: inherit; border-color: rgba(107,74,31,.35); padding: .3rem .6rem; }\n .lataus { display: inline-flex; margin: 0 0 .5rem; }\n .lataus span {\n padding: .45rem .8rem; border: 1px solid rgba(0,0,0,.25); border-radius: .35rem;\n cursor: pointer; font-weight: 600;\n }\n .lataus input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }\n .virheviesti { margin: 0 0 .5rem; color: #8c3b2e; font-size: .8125rem; }\n .tyhja { margin: 0; opacity: .6; }\n .kohteet { list-style: none; margin: 0 0 .75rem; padding: 0; display: grid; gap: .25rem; }\n .kohteet button {\n width: 100%; text-align: left; color: inherit; border-color: rgba(0,0,0,.15);\n display: flex; justify-content: space-between; gap: .5rem; align-items: center;\n }\n .kohteet button.valittu { background: #1d1f1e; color: #fff; border-color: #1d1f1e; }\n .merkki { font-size: .75rem; opacity: .7; white-space: nowrap; }\n /* Taitos n\u00E4ytt\u00E4\u00E4 painikkeelta ja nuoli osoittaa sinne, minne sis\u00E4lt\u00F6\n aukeaa: alas kiinni ollessa, yl\u00F6s auki ollessa. */\n .ryhma { margin: 0 0 1rem; padding: .75rem .8rem; border-radius: .3rem; background: rgba(0,0,0,.035); }\n .ryhma h3 { margin: 0 0 .5rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .05em; opacity: .65; }\n .ryhma .kentta:last-child { margin-bottom: 0; }\n .lisatiedot { margin: .25rem 0 1rem; border-top: 1px solid rgba(0,0,0,.1); }\n .lisatiedot summary {\n cursor: pointer; font-weight: 600; padding: .7rem 0; list-style: none;\n display: flex; align-items: center; gap: .5rem;\n }\n .lisatiedot summary::-webkit-details-marker { display: none; }\n .lisatiedot summary::after {\n content: \"\u25BE\"; font-size: 1.1rem; line-height: 1; margin-left: auto;\n }\n .lisatiedot[open] summary::after { content: \"\u25B4\"; }\n .lisatiedot summary:hover { text-decoration: underline; }\n .lisatiedot summary:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n .kaannos { margin: 0 0 1rem; padding: .6rem .7rem; border-radius: .25rem; background: #eef1ee; }\n .kaannos p { margin: 0 0 .5rem; }\n /* Kenttien nimet omalle rivilleen: lause pysyy luettavana ja nimet erottuvat. */\n .kaannos strong { display: block; margin-top: .15rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit { display: flex; gap: .5rem; margin-bottom: 1rem; }\n .napit button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit button.vaarallinen { color: #8c3b2e; border-color: rgba(140,59,46,.4); }\n";
1
+ declare const PALKIN_TYYLIT = "\n :host { all: initial; }\n .palkki {\n position: fixed; inset: auto 1rem 1rem auto; z-index: 2147483000;\n display: flex; align-items: center; gap: .75rem;\n padding: .6rem .75rem .6rem 1rem; border-radius: .5rem;\n background: #1d1f1e; color: #fff;\n font: 500 .875rem/1.4 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: 0 .5rem 1.5rem rgba(0,0,0,.25);\n max-width: calc(100vw - 2rem);\n }\n .tila { opacity: .75; font-weight: 400; }\n .tila[data-virhe=\"1\"] { color: #ffb4a2; opacity: 1; }\n button {\n font: inherit; padding: .45rem .8rem; border-radius: .35rem;\n border: 1px solid rgba(255,255,255,.25); background: transparent; color: inherit;\n cursor: pointer;\n }\n button.ensisijainen { background: #fff; color: #1d1f1e; border-color: #fff; }\n button:disabled { opacity: .5; cursor: default; }\n button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n /* Sivupaneeli */\n .paneeli {\n position: fixed; inset: 0 0 0 auto; z-index: 2147482999;\n width: min(23rem, 100vw); overflow-y: auto;\n padding: 1.25rem 1.25rem 6rem;\n background: #fbfaf8; color: #1d1f1e;\n border-left: 1px solid rgba(0,0,0,.12);\n font: 400 .875rem/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n box-shadow: -.5rem 0 1.5rem rgba(0,0,0,.08);\n }\n .paneeli h2 { margin: 0 0 .75rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .06em; opacity: .6; }\n .paneeli section + section { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid rgba(0,0,0,.1); }\n .kentta { margin-bottom: 1rem; }\n .kentta label { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .25rem; font-weight: 600; }\n .kieli { font-weight: 400; font-size: .75rem; padding: 0 .3rem; border-radius: .2rem; background: rgba(0,0,0,.08); text-transform: uppercase; }\n .kentta input[type=text], .kentta input[type=number], .kentta input[type=date],\n .kentta input[type=datetime-local], .kentta select, .kentta textarea {\n width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25);\n border-radius: .25rem; background: #fff; font: inherit; color: inherit;\n }\n .kentta textarea { resize: vertical; }\n .kytkin { display: flex; gap: .5rem; align-items: center; font-weight: 400; }\n .kielivalinta { display: grid; gap: .35rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .ohje { margin: .25rem 0 0; font-size: .8125rem; opacity: .65; }\n .laskuri { margin: .3rem 0 0; font-size: .8125rem; font-weight: 600; }\n /* Pakollinen kentt\u00E4: punainen t\u00E4hti on tuttu merkki, eik\u00E4 se vie tilaa. */\n .pakko { color: #b3261e; font-weight: 700; }\n .piilossa {\n position: absolute; width: 1px; height: 1px; overflow: hidden;\n clip-path: inset(50%); white-space: nowrap;\n }\n .linkki {\n background: none; border: 0; padding: 0; margin: 0; font: inherit;\n color: inherit; text-decoration: underline; cursor: pointer;\n }\n .virheet { padding: .75rem .9rem; border-radius: .3rem; background: #f6e8e3; color: #6d2b20; }\n .virheet h2 { margin: 0 0 .4rem; opacity: 1; color: inherit; }\n .virheet ul { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n .kuva img { display: block; width: 100%; height: auto; border-radius: .25rem; margin-bottom: .4rem; }\n .kuva input[type=text] { width: 100%; padding: .45rem .55rem; border: 1px solid rgba(0,0,0,.25); border-radius: .25rem; font: inherit; }\n /* Kuvan tekstikentt\u00E4 on oma lohkonsa: label on .kentta-tasolla flex,\n ja ilman t\u00E4t\u00E4 otsikko ja kentt\u00E4 asettuisivat vierekk\u00E4in. */\n .kentta label.alt, .kuva label.alt { display: block; font-weight: 600; margin-top: .5rem; }\n .alt input { margin-top: .25rem; width: 100%; }\n .huomio {\n margin: .4rem 0 0; padding: .5rem .6rem; border-radius: .25rem;\n background: #fdf3e7; color: #6b4a1f; font-size: .8125rem;\n }\n .huomio p { margin: 0 0 .4rem; }\n .huomio button { color: inherit; border-color: rgba(107,74,31,.35); padding: .3rem .6rem; }\n .lataus { display: inline-flex; margin: 0 0 .5rem; }\n .lataus span {\n padding: .45rem .8rem; border: 1px solid rgba(0,0,0,.25); border-radius: .35rem;\n cursor: pointer; font-weight: 600;\n }\n .lataus input[type=file] { position: absolute; width: 1px; height: 1px; opacity: 0; }\n .virheviesti { margin: 0 0 .5rem; color: #8c3b2e; font-size: .8125rem; }\n .tyhja { margin: 0; opacity: .6; }\n .kohteet { list-style: none; margin: 0 0 .75rem; padding: 0; display: grid; gap: .25rem; }\n .kohteet button {\n width: 100%; text-align: left; color: inherit; border-color: rgba(0,0,0,.15);\n display: flex; justify-content: space-between; gap: .5rem; align-items: center;\n }\n .kohteet button.valittu { background: #1d1f1e; color: #fff; border-color: #1d1f1e; }\n .merkki { font-size: .75rem; opacity: .7; white-space: nowrap; }\n /* Taitos n\u00E4ytt\u00E4\u00E4 painikkeelta ja nuoli osoittaa sinne, minne sis\u00E4lt\u00F6\n aukeaa: alas kiinni ollessa, yl\u00F6s auki ollessa. */\n .ryhma { margin: 0 0 1rem; padding: .75rem .8rem; border-radius: .3rem; background: rgba(0,0,0,.035); }\n .ryhma h3 { margin: 0 0 .5rem; font-size: .8125rem; text-transform: uppercase; letter-spacing: .05em; opacity: .65; }\n .ryhma .kentta:last-child { margin-bottom: 0; }\n .lisatiedot { margin: .25rem 0 1rem; border-top: 1px solid rgba(0,0,0,.1); }\n .lisatiedot summary {\n cursor: pointer; font-weight: 600; padding: .7rem 0; list-style: none;\n display: flex; align-items: center; gap: .5rem;\n }\n .lisatiedot summary::-webkit-details-marker { display: none; }\n .lisatiedot summary::after {\n content: \"\u25BE\"; font-size: 1.1rem; line-height: 1; margin-left: auto;\n }\n .lisatiedot[open] summary::after { content: \"\u25B4\"; }\n .lisatiedot summary:hover { text-decoration: underline; }\n .lisatiedot summary:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n .kaannos { margin: 0 0 1rem; padding: .6rem .7rem; border-radius: .25rem; background: #eef1ee; }\n .kaannos p { margin: 0 0 .5rem; }\n /* Kenttien nimet omalle rivilleen: lause pysyy luettavana ja nimet erottuvat. */\n .kaannos strong { display: block; margin-top: .15rem; }\n .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit { display: flex; gap: .5rem; margin-bottom: 1rem; }\n .napit button { color: inherit; border-color: rgba(0,0,0,.25); }\n .napit button.vaarallinen { color: #8c3b2e; border-color: rgba(140,59,46,.4); }\n";
2
2
  export declare function Muokkaustila(): import("react").JSX.Element | null;
3
3
  export { PALKIN_TYYLIT };
@@ -72,6 +72,16 @@ const PALKIN_TYYLIT = `
72
72
  .kaannos button { color: inherit; border-color: rgba(0,0,0,.25); }
73
73
  .ohje { margin: .25rem 0 0; font-size: .8125rem; opacity: .65; }
74
74
  .laskuri { margin: .3rem 0 0; font-size: .8125rem; font-weight: 600; }
75
+ /* Pakollinen kenttä: punainen tähti on tuttu merkki, eikä se vie tilaa. */
76
+ .pakko { color: #b3261e; font-weight: 700; }
77
+ .piilossa {
78
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
79
+ clip-path: inset(50%); white-space: nowrap;
80
+ }
81
+ .linkki {
82
+ background: none; border: 0; padding: 0; margin: 0; font: inherit;
83
+ color: inherit; text-decoration: underline; cursor: pointer;
84
+ }
75
85
  .virheet { padding: .75rem .9rem; border-radius: .3rem; background: #f6e8e3; color: #6d2b20; }
76
86
  .virheet h2 { margin: 0 0 .4rem; opacity: 1; color: inherit; }
77
87
  .virheet ul { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }
@@ -159,12 +169,15 @@ function lisaaTyylit() {
159
169
  function lueTeksti(elementti, lista) {
160
170
  return (lista ? elementti.innerText : elementti.textContent) ?? "";
161
171
  }
162
- /** Sivulla kirjoitettu kappalelista takaisin listaksi. */
163
- function jaaKappaleiksi(teksti) {
164
- return teksti
165
- .split(/\n+/)
166
- .map((kappale) => kappale.trim())
167
- .filter((kappale) => kappale !== "");
172
+ /**
173
+ * Sivulla kirjoitettu lista takaisin listaksi.
174
+ *
175
+ * Oletuksena yksi rivi on yksi kohta. Jos sivusto näyttää listan yhtenä
176
+ * tekstinä (esim. "Lounas, Materiaalit"), erotin tulee sisältömallista.
177
+ */
178
+ function jaaKappaleiksi(teksti, erotin) {
179
+ const osat = erotin ? teksti.split(erotin.trim()) : teksti.split(/\n+/);
180
+ return osat.map((osa) => osa.trim()).filter((osa) => osa !== "");
168
181
  }
169
182
  /** Sivulla olevat muokattavat kohdat merkintöineen. */
170
183
  function merkinnat() {
@@ -325,8 +338,10 @@ export function Muokkaustila() {
325
338
  continue;
326
339
  if (onKaannettava(kenttä))
327
340
  uusi[avain] = {};
341
+ /* Uusi kohde syntyy piilotettuna, kun mallissa on piilottava
342
+ kytkin: keskeneräinen kurssi ei kuulu kävijän silmien eteen. */
328
343
  else if (kenttä.tyyppi === "kytkin")
329
- uusi[avain] = false;
344
+ uusi[avain] = kenttä.piilottaa === true;
330
345
  else if (kenttä.tyyppi === "tekstilista")
331
346
  uusi[avain] = [];
332
347
  else if (kenttä.tyyppi === "valinta")
@@ -386,6 +401,9 @@ export function Muokkaustila() {
386
401
  }
387
402
  return {
388
403
  src: tulos.src,
404
+ /* Esikatselu on itse kuva: tallennettu tiedosto näkyy osoitteessaan
405
+ vasta seuraavan julkaisun jälkeen. */
406
+ esikatselu: typeof tulos.esikatselu === "string" ? tulos.esikatselu : null,
389
407
  ehdotus: typeof tulos.ehdotus === "string" ? tulos.ehdotus : null,
390
408
  };
391
409
  }, []);
@@ -501,14 +519,38 @@ export function Muokkaustila() {
501
519
  muutetut[sivu] = { ...data };
502
520
  }
503
521
  }
522
+ /* Sivulla kirjoitetut luvut tarkistetaan tässä: palvelimelle ei kannata
523
+ lähettää "kymmenen", kun virheen näkee jo selaimessa. */
524
+ const sivunVirheet = [];
504
525
  for (const { elementti, polku } of merkinnat()) {
505
526
  const maaritys = kentanMaaritys(sisalto, polku);
506
527
  const lista = maaritys?.tyyppi === "tekstilista";
507
528
  const teksti = lueTeksti(elementti, lista);
508
529
  if (teksti === alkuperaiset.current.get(elementti))
509
530
  continue;
531
+ let arvo = teksti;
532
+ if (lista && maaritys?.tyyppi === "tekstilista") {
533
+ arvo = jaaKappaleiksi(teksti, maaritys.erotin);
534
+ }
535
+ else if (maaritys?.tyyppi === "luku") {
536
+ const luku = Number(teksti.replace(",", ".").trim());
537
+ if (teksti.trim() === "" || !Number.isFinite(luku)) {
538
+ sivunVirheet.push({
539
+ kohde: maaritys.nimi ?? polku.alikentta ?? polku.kentta,
540
+ viesti: "Kirjoita pelkkä numero.",
541
+ });
542
+ continue;
543
+ }
544
+ arvo = luku;
545
+ }
510
546
  const kieli = onKaannettava(maaritys) ? kentanKieli(elementti, sisalto.kielet) : undefined;
511
- muutetut[polku.sivu] = asetaArvo(pohja(polku.sivu), polku, lista ? jaaKappaleiksi(teksti) : teksti, kieli, tunnusKentta(sisalto, polku));
547
+ muutetut[polku.sivu] = asetaArvo(pohja(polku.sivu), polku, arvo, kieli, tunnusKentta(sisalto, polku));
548
+ }
549
+ if (sivunVirheet.length > 0) {
550
+ setTila("muokkaa");
551
+ setViesti("Tarkista sivulla merkityt kohdat. Muutoksesi ovat yhä tässä.");
552
+ setVirheet(sivunVirheet);
553
+ return;
512
554
  }
513
555
  /* Uusi kohde sai tunnuksen "uusi". Nyt kun sillä on nimi, tunnus
514
556
  johdetaan siitä: se näkyy osoitteessa, eikä kukaan halua /uusi-2. */
@@ -614,7 +656,7 @@ export function Muokkaustila() {
614
656
  kohde: kaannettavat
615
657
  .filter(({ polku }) => polku.tunnus !== undefined)
616
658
  .map(({ nimi }) => nimi),
617
- }, valitse: setValittu, lisaaKohde: lisaaKohde, poistaKohde: poistaKohde })) : null, _jsx("div", { className: "palkki", role: "region", "aria-label": "Sis\u00E4ll\u00F6n muokkaus", children: tila === "lataa" ? (_jsx("span", { className: "tila", children: "Editori avautuu\u2026" })) : muokataan || tallentaa ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "tila", role: "status", "data-virhe": viesti ? "1" : "0", children: tallentaa ? "Tallennetaan…" : (viesti ?? "") !== "" ? viesti : "Muokkaat sivua" }), _jsx("button", { type: "button", onClick: peru, disabled: tallentaa, children: "Peru" }), _jsx("button", { type: "button", className: "ensisijainen", onClick: tallenna, disabled: tallentaa, children: "Tallenna" })] })) : (_jsxs(_Fragment, { children: [viesti ? (_jsx("span", { className: "tila", role: "status", "data-virhe": tila === "virhe" ? "1" : "0", children: viesti })) : null, tila === "tallennettu" ? (_jsx("button", { type: "button", onClick: () => location.reload(), children: "P\u00E4ivit\u00E4 sivu" })) : null, sisalto ? (_jsx("button", { type: "button", className: "ensisijainen", onClick: paalle, children: "Muokkaa" })) : (_jsx("button", { type: "button", onClick: () => location.reload(), children: "Lataa uudelleen" }))] })) })] }));
659
+ }, valitse: setValittu, lisaaKohde: lisaaKohde, poistaKohde: poistaKohde })) : null, _jsx("div", { className: "palkki", role: "region", "aria-label": "Sis\u00E4ll\u00F6n muokkaus", children: tila === "lataa" ? (_jsx("span", { className: "tila", children: "Editori avautuu\u2026" })) : muokataan || tallentaa ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "tila", role: "status", "data-virhe": viesti ? "1" : "0", children: tallentaa ? "Tallennetaan…" : (viesti ?? "") !== "" ? viesti : "Muokkaat sivua" }), _jsx("button", { type: "button", onClick: peru, disabled: tallentaa, children: "Peru" }), _jsx("button", { type: "button", className: "ensisijainen", onClick: tallenna, disabled: tallentaa, children: "Tallenna" })] })) : (_jsxs(_Fragment, { children: [viesti ? (_jsx("span", { className: "tila", role: "status", "data-virhe": tila === "virhe" ? "1" : "0", children: viesti })) : null, tila === "tallennettu" ? (_jsx("button", { type: "button", onClick: () => location.reload(), children: "P\u00E4ivit\u00E4 sivu" })) : null, sisalto && malli ? (_jsx("button", { type: "button", className: "ensisijainen", onClick: paalle, children: "Muokkaa" })) : sisalto ? (_jsx("span", { className: "tila", children: "T\u00E4ll\u00E4 sivulla ei ole muokattavaa sis\u00E4lt\u00F6\u00E4." })) : (_jsx("button", { type: "button", onClick: () => location.reload(), children: "Lataa uudelleen" }))] })) })] }));
618
660
  }
619
661
  /** Mitä yhtä kohdetta tässä listassa kutsutaan, esimerkiksi "kurssi". */
620
662
  function kohdelistanYksikko(sisalto, sivu, kentta) {
@@ -7,6 +7,7 @@ export interface PaneelinTiedot {
7
7
  tyyppi: "kuva";
8
8
  }>): Promise<{
9
9
  src: string;
10
+ esikatselu: string | null;
10
11
  ehdotus: string | null;
11
12
  }>;
12
13
  sivu: string;
@@ -48,10 +48,15 @@ function Sisalto({ maaritys, arvo, tunniste, muuta, lataa, kielet, }) {
48
48
  case "tekstialue":
49
49
  return (_jsx("textarea", { id: tunniste, rows: 4, value: typeof arvo === "string" ? arvo : "", onChange: (tapahtuma) => muuta(tapahtuma.target.value) }));
50
50
  case "tekstilista":
51
+ /*
52
+ Yksi rivi = yksi kappale, sama sääntö kuin sivulla muokatessa.
53
+ Tyhjät rivit ohitetaan, joten ylimääräinen rivinvaihto ei tuota
54
+ tyhjää kappaletta.
55
+ */
51
56
  return (_jsx("textarea", { id: tunniste, rows: 5, value: Array.isArray(arvo) ? arvo.join("\n\n") : "", onChange: (tapahtuma) => muuta(tapahtuma.target.value
52
- .split(/\n{2,}/)
53
- .map((kohta) => kohta.trim())
54
- .filter((kohta) => kohta !== "")) }));
57
+ .split(/\n+/)
58
+ .map((kappale) => kappale.trim())
59
+ .filter((kappale) => kappale !== "")) }));
55
60
  case "kielivalinta": {
56
61
  const valitut = Array.isArray(arvo) ? arvo : [];
57
62
  return (_jsxs("div", { className: "kielivalinta", children: [kielet.map((kieli) => (_jsxs("label", { className: "kytkin", children: [_jsx("input", { type: "checkbox", checked: valitut.includes(kieli), onChange: (tapahtuma) => muuta(tapahtuma.target.checked
@@ -80,15 +85,15 @@ function Kentta_({ polku, maaritys, tiedot, }) {
80
85
  const tunniste = `kentta-${polku.kentta}-${polku.tunnus ?? ""}-${(polku.alikentta ?? "").replace(".", "-")}`;
81
86
  const kaannettava = "kaannettava" in maaritys && maaritys.kaannettava === true;
82
87
  const arvo = nykyinenArvo(maaritys, lueArvo(tiedot.data, polku, tunnuksenNimi(tiedot, polku.kentta)), tiedot.kieli);
83
- return (_jsxs("div", { className: "kentta", children: [_jsxs("label", { htmlFor: tunniste, children: [nimi, kaannettava ? _jsx("span", { className: "kieli", children: tiedot.kieli }) : null] }), _jsx(Sisalto, { maaritys: maaritys, arvo: arvo, tunniste: tunniste, kielet: tiedot.kielet, muuta: (uusi) => tiedot.muuta(polku, uusi, kaannettava), lataa: maaritys.tyyppi === "kuva"
88
+ return (_jsxs("div", { className: "kentta", children: [_jsxs("label", { htmlFor: tunniste, children: [nimi, maaritys.pakollinen ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "pakko", "aria-hidden": "true", title: "Pakollinen tieto", children: "*" }), _jsx("span", { className: "piilossa", children: " (pakollinen)" })] })) : null, kaannettava ? _jsx("span", { className: "kieli", children: tiedot.kieli }) : null] }), _jsx(Sisalto, { maaritys: maaritys, arvo: arvo, tunniste: tunniste, kielet: tiedot.kielet, muuta: (uusi) => tiedot.muuta(polku, uusi, kaannettava), lataa: maaritys.tyyppi === "kuva"
84
89
  ? async (tiedosto) => {
85
- const { src, ehdotus } = await tiedot.lataaKuva(tiedosto, maaritys);
90
+ const { src, ehdotus, esikatselu } = await tiedot.lataaKuva(tiedosto, maaritys);
86
91
  const vanha = onOlio(arvo) ? arvo : {};
87
92
  /* Tyhjä teksti täytetään ehdotuksella suoraan; olemassa
88
93
  olevaa ei ylikirjoiteta, vaan ehdotus tarjotaan. */
89
94
  const alt = typeof vanha.alt === "string" ? vanha.alt.trim() : "";
90
95
  tiedot.muuta(polku, { ...vanha, src, ...(alt === "" && ehdotus ? { alt: ehdotus } : {}) }, false);
91
- return ehdotus;
96
+ return { ehdotus, esikatselu };
92
97
  }
93
98
  : undefined }), _jsx(Vapaana, { polku: polku, maaritys: maaritys, arvo: arvo, tiedot: tiedot }), maaritys.ohje ? _jsx("p", { className: "ohje", children: maaritys.ohje }) : null] }));
94
99
  }
@@ -110,6 +115,67 @@ function Vapaana({ polku, maaritys, arvo, tiedot, }) {
110
115
  const vapaana = Math.max(0, yla - arvo);
111
116
  return (_jsx("p", { className: "laskuri", role: "status", children: vapaana === 0 ? "Täynnä" : `Vapaana ${vapaana} / ${yla} paikkaa` }));
112
117
  }
118
+ /**
119
+ * Kohteen nimi listassa.
120
+ *
121
+ * Nykyinen kieli ensin, mutta kääntämätön kurssi näkyy sillä kielellä, jolla
122
+ * se on kirjoitettu — ei nimettömänä.
123
+ */
124
+ /** Onko kohde vasta lisätty eikä sitä ole vielä tallennettu? */
125
+ function uusi(tunnus) {
126
+ return /^uusi(-\d+)?$/.test(tunnus);
127
+ }
128
+ function kohteenOtsikko(maaritys, kohde, kieli) {
129
+ const arvo = kohde[maaritys.otsikko];
130
+ if (typeof arvo === "string")
131
+ return arvo === "" ? "(nimetön)" : arvo;
132
+ if (!onOlio(arvo))
133
+ return "(nimetön)";
134
+ const omalla = arvo[kieli];
135
+ if (typeof omalla === "string" && omalla !== "")
136
+ return omalla;
137
+ const joku = Object.values(arvo).find((muu) => typeof muu === "string" && muu !== "");
138
+ return joku ?? "(nimetön)";
139
+ }
140
+ /**
141
+ * Virheen kohde muokkaajan sanoin.
142
+ *
143
+ * Palvelin kertoo polun ("kurssit.lista.testi.dateLabel"), mutta muokkaaja
144
+ * tuntee kentät niiden nimillä. Nimi haetaan sisältömallista, ja listan
145
+ * sisällä mukaan tulee se, minkä kohteen kentästä on kyse — muuten viisi
146
+ * virhettä näyttää samalta.
147
+ */
148
+ function virheenKohde(tiedot, kohde) {
149
+ const osat = kohde.split(".");
150
+ const kentta = osat[1];
151
+ const maaritys = kentta ? tiedot.malli.kentat[kentta] : undefined;
152
+ if (!maaritys)
153
+ return { nimi: osat.at(-1) ?? kohde, sivulla: false };
154
+ if (maaritys.tyyppi !== "kohdelista") {
155
+ return {
156
+ nimi: maaritys.nimi ?? kentta,
157
+ sivulla: tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${kentta}`),
158
+ };
159
+ }
160
+ const tunnus = osat[2];
161
+ const alikentta = osat[3];
162
+ const alimaaritys = alikentta ? maaritys.kentat[alikentta] : undefined;
163
+ let nimi = alimaaritys?.nimi ?? alikentta ?? maaritys.nimi ?? kentta;
164
+ if (alimaaritys?.tyyppi === "ryhma" && osat[4]) {
165
+ const sisempi = alimaaritys.kentat[osat[4]];
166
+ nimi = `${alimaaritys.nimi ?? alikentta}: ${sisempi?.nimi ?? osat[4]}`;
167
+ }
168
+ const kohteet = Array.isArray(tiedot.data[kentta])
169
+ ? tiedot.data[kentta]
170
+ : [];
171
+ const osuma = kohteet.find((yksi) => yksi[maaritys.tunnus] === tunnus);
172
+ return {
173
+ nimi,
174
+ ...(osuma ? { kohteenNimi: kohteenOtsikko(maaritys, osuma, tiedot.kieli) } : {}),
175
+ ...(tunnus === undefined ? {} : { tunnus }),
176
+ sivulla: tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${kentta}[${tunnus}].${alikentta}`),
177
+ };
178
+ }
113
179
  export function Paneeli(tiedot) {
114
180
  const kentat = Object.entries(tiedot.malli.kentat);
115
181
  const lista = kentat.find(([, maaritys]) => maaritys.tyyppi === "kohdelista");
@@ -120,7 +186,10 @@ export function Paneeli(tiedot) {
120
186
  return !tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${avain}`);
121
187
  };
122
188
  const omat = kentat.filter(([avain, maaritys]) => maaritys.tyyppi !== "kohdelista" && nayta([avain, maaritys]));
123
- return (_jsxs("aside", { className: "paneeli", "aria-label": "Sis\u00E4ll\u00F6n tiedot", children: [tiedot.virheet && tiedot.virheet.length > 0 ? (_jsxs("section", { className: "virheet", role: "alert", children: [_jsx("h2", { children: "Tallennus pys\u00E4htyi" }), _jsx("ul", { children: tiedot.virheet.slice(0, 6).map((virhe) => (_jsxs("li", { children: [_jsx("strong", { children: virhe.kohde.split(".").at(-1) }), " ", virhe.viesti] }, `${virhe.kohde}-${virhe.viesti}`))) })] })) : null, omat.length > 0 || tiedot.kaannettavia.sivu.length > 0 ? (_jsxs("section", { children: [_jsx("h2", { children: tiedot.malli.nimi }), omat.map(([avain, maaritys]) => (_jsx(Kentta_, { polku: { sivu: tiedot.sivu, kentta: avain }, maaritys: maaritys, tiedot: tiedot }, avain))), tiedot.kielet.length > 1 ? _jsx(Kaannos, { tiedot: tiedot, mihin: "sivu" }) : null] })) : null, lista ? _jsx(Kohteet, { avain: lista[0], maaritys: lista[1], tiedot: tiedot }) : null] }));
189
+ return (_jsxs("aside", { className: "paneeli", "aria-label": "Sis\u00E4ll\u00F6n tiedot", children: [tiedot.virheet && tiedot.virheet.length > 0 ? (_jsxs("section", { className: "virheet", role: "alert", children: [_jsx("h2", { children: "Tallennus pys\u00E4htyi" }), _jsx("ul", { children: tiedot.virheet.slice(0, 6).map((virhe) => {
190
+ const tiedot_ = virheenKohde(tiedot, virhe.kohde);
191
+ return (_jsxs("li", { children: [_jsx("strong", { children: tiedot_.nimi }), " ", virhe.viesti, tiedot_.kohteenNimi ? (_jsx("button", { type: "button", className: "linkki", onClick: () => tiedot_.tunnus && tiedot.valitse(tiedot_.tunnus), children: tiedot_.kohteenNimi })) : null, tiedot_.sivulla ? _jsx("span", { className: "merkki", children: " Muokataan sivulla." }) : null] }, `${virhe.kohde}-${virhe.viesti}`));
192
+ }) })] })) : null, omat.length > 0 || tiedot.kaannettavia.sivu.length > 0 ? (_jsxs("section", { children: [_jsx("h2", { children: tiedot.malli.nimi }), omat.map(([avain, maaritys]) => (_jsx(Kentta_, { polku: { sivu: tiedot.sivu, kentta: avain }, maaritys: maaritys, tiedot: tiedot }, avain))), tiedot.kielet.length > 1 ? _jsx(Kaannos, { tiedot: tiedot, mihin: "sivu" }) : null] })) : null, lista ? _jsx(Kohteet, { avain: lista[0], maaritys: lista[1], tiedot: tiedot }) : null] }));
124
193
  }
125
194
  const KIELELLE = {
126
195
  fi: "suomeksi",
@@ -164,20 +233,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
164
233
  const kohteet = Array.isArray(tiedot.data[avain])
165
234
  ? tiedot.data[avain]
166
235
  : [];
167
- const nimi = (kohde) => {
168
- const arvo = kohde[maaritys.otsikko];
169
- if (typeof arvo === "string")
170
- return arvo === "" ? "(nimetön)" : arvo;
171
- if (!onOlio(arvo))
172
- return "(nimetön)";
173
- /* Nykyinen kieli ensin, mutta kääntämätön kurssi näkyy listassa sillä
174
- kielellä, jolla se on kirjoitettu — ei nimettömänä. */
175
- const omalla = arvo[tiedot.kieli];
176
- if (typeof omalla === "string" && omalla !== "")
177
- return omalla;
178
- const joku = Object.values(arvo).find((muu) => typeof muu === "string" && muu !== "");
179
- return joku ?? "(nimetön)";
180
- };
236
+ const nimi = (kohde) => kohteenOtsikko(maaritys, kohde, tiedot.kieli);
181
237
  /**
182
238
  * Merkinnät kohteen nimen perässä: piilotus, huomionarvoinen tila ja
183
239
  * täyttyminen. Näin listasta näkee tilanteen avaamatta jokaista kohdetta.
@@ -223,7 +279,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
223
279
  const tunnus = String(kohde[maaritys.tunnus] ?? "");
224
280
  const omatMerkinnat = merkinnat(kohde);
225
281
  return (_jsx("li", { children: _jsxs("button", { type: "button", className: tunnus === valitunTunnus ? "valittu" : undefined, "aria-current": tunnus === valitunTunnus ? "true" : undefined, onClick: () => tiedot.valitse(tunnus), children: [_jsx("span", { children: nimi(kohde) }), omatMerkinnat.length > 0 ? (_jsx("span", { className: "merkki", children: omatMerkinnat.join(" · ") })) : null] }) }, tunnus));
226
- }) }), _jsx("div", { className: "napit", children: _jsxs("button", { type: "button", onClick: () => tiedot.lisaaKohde(avain, maaritys), children: ["+ Lis\u00E4\u00E4 ", yksikko] }) }), valittu ? null : (_jsxs("p", { className: "ohje", children: ["Valitse ", yksikko, ", niin n\u00E4et sen tiedot."] }))] }), valittu && valitunTunnus ? (_jsxs("section", { children: [_jsx("h2", { children: nimi(valittu) }), ensisijaiset.map(kentta), tiedot.kielet.length > 1 ? _jsx(Kaannos, { tiedot: tiedot, mihin: "kohde" }) : null, lisatiedot.length > 0 ? (_jsxs("details", { className: "lisatiedot", children: [_jsx("summary", { children: "Lis\u00E4tiedot" }), lisatiedot.map(kentta)] })) : null, _jsx("div", { className: "napit", children: _jsxs("button", { type: "button", className: "vaarallinen", onClick: () => tiedot.poistaKohde(avain, maaritys.tunnus, valitunTunnus), children: ["Poista ", yksikko] }) })] })) : null] }));
282
+ }) }), _jsx("div", { className: "napit", children: _jsxs("button", { type: "button", onClick: () => tiedot.lisaaKohde(avain, maaritys), children: ["+ Lis\u00E4\u00E4 ", yksikko] }) }), valittu ? null : (_jsxs("p", { className: "ohje", children: ["Valitse ", yksikko, ", niin n\u00E4et sen tiedot."] }))] }), valittu && valitunTunnus ? (_jsxs("section", { children: [_jsx("h2", { children: nimi(valittu) }), maaritys.osoite ? (uusi(valitunTunnus) ? (_jsx("p", { className: "ohje", children: "Oma sivu syntyy, kun tallennat." })) : (_jsx("p", { className: "ohje", children: _jsx("a", { className: "linkki", href: maaritys.osoite.replace("{tunnus}", valitunTunnus), children: "Avaa sivu" }) }))) : null, ensisijaiset.map(kentta), tiedot.kielet.length > 1 ? _jsx(Kaannos, { tiedot: tiedot, mihin: "kohde" }) : null, lisatiedot.length > 0 ? (_jsxs("details", { className: "lisatiedot", children: [_jsx("summary", { children: "Lis\u00E4tiedot" }), lisatiedot.map(kentta)] })) : null, _jsx("div", { className: "napit", children: _jsxs("button", { type: "button", className: "vaarallinen", onClick: () => tiedot.poistaKohde(avain, maaritys.tunnus, valitunTunnus), children: ["Poista ", yksikko] }) })] })) : null] }));
227
283
  }
228
284
  /**
229
285
  * Kuvakenttä: nykyinen kuva, vaihto ja vaihtoehtoinen teksti.
@@ -237,6 +293,9 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
237
293
  /* Vasta vaihdettu kuva: vanha teksti kuvaa yhä vanhaa kuvaa. */
238
294
  const [vaihdettu, setVaihdettu] = useState(false);
239
295
  const [ehdotus, setEhdotus] = useState(null);
296
+ /* Juuri ladattu kuva näytetään sellaisenaan: tallennettu tiedosto ilmestyy
297
+ osoitteeseensa vasta, kun sivusto on rakennettu uudelleen. */
298
+ const [esikatselu, setEsikatselu] = useState(null);
240
299
  const kuva = onOlio(arvo) ? arvo : undefined;
241
300
  async function valitse(tiedosto) {
242
301
  if (!tiedosto)
@@ -246,9 +305,10 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
246
305
  setEhdotus(null);
247
306
  try {
248
307
  const nykyinenAlt = typeof kuva?.alt === "string" ? kuva.alt.trim() : "";
249
- const uusiEhdotus = await lataa(tiedosto);
308
+ const { ehdotus: uusiEhdotus, esikatselu: uusiKuva } = await lataa(tiedosto);
250
309
  setTila("valmis");
251
310
  setVaihdettu(true);
311
+ setEsikatselu(uusiKuva);
252
312
  /* Jos kentässä oli jo teksti, ehdotusta tarjotaan mutta ei tyrkytetä. */
253
313
  if (uusiEhdotus && nykyinenAlt !== "")
254
314
  setEhdotus(uusiEhdotus);
@@ -258,7 +318,7 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
258
318
  setVirhe(syy instanceof Error ? syy.message : "Kuvan lataus ei onnistunut.");
259
319
  }
260
320
  }
261
- return (_jsxs("div", { className: "kuva", children: [typeof kuva?.src === "string" ? (_jsx("img", { src: kuva.src, alt: "" })) : (_jsx("p", { className: "tyhja", children: "Ei kuvaa." })), _jsxs("label", { className: "lataus", children: [_jsx("span", { children: tila === "lataa" ? "Käsitellään…" : "Vaihda kuva" }), _jsx("input", { type: "file", accept: "image/*", disabled: tila === "lataa", onChange: (tapahtuma) => valitse(tapahtuma.target.files?.[0]) })] }), tila === "virhe" ? _jsx("p", { className: "virheviesti", children: virhe }) : null, _jsxs("label", { className: "alt", children: ["Kuvan kuvaus", _jsx("input", { type: "text", placeholder: "Esimerkiksi: kynttil\u00E4 ikkunalaudalla", value: typeof kuva?.alt === "string" ? kuva.alt : "", onChange: (tapahtuma) => {
321
+ return (_jsxs("div", { className: "kuva", children: [esikatselu ?? (typeof kuva?.src === "string" ? kuva.src : null) ? (_jsxs(_Fragment, { children: [_jsx("img", { src: esikatselu ?? kuva?.src, alt: "" }), esikatselu ? (_jsx("p", { className: "ohje", children: "Uusi kuva. Se tulee sivulle, kun tallennat." })) : null] })) : (_jsx("p", { className: "tyhja", children: "Ei kuvaa." })), _jsxs("label", { className: "lataus", children: [_jsx("span", { children: tila === "lataa" ? "Käsitellään…" : "Vaihda kuva" }), _jsx("input", { type: "file", accept: "image/*", disabled: tila === "lataa", onChange: (tapahtuma) => valitse(tapahtuma.target.files?.[0]) })] }), tila === "virhe" ? _jsx("p", { className: "virheviesti", children: virhe }) : null, _jsxs("label", { className: "alt", children: ["Kuvan kuvaus", _jsx("input", { type: "text", placeholder: "Esimerkiksi: kynttil\u00E4 ikkunalaudalla", value: typeof kuva?.alt === "string" ? kuva.alt : "", onChange: (tapahtuma) => {
262
322
  setVaihdettu(false);
263
323
  muuta({ ...(kuva ?? {}), alt: tapahtuma.target.value });
264
324
  } })] }), ehdotus ? (_jsxs("div", { className: "huomio", role: "status", children: [_jsxs("p", { children: ["Ehdotus: \u201C", ehdotus, "\u201D"] }), _jsx("button", { type: "button", onClick: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiotitta/studio-editori",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Studio Titan sisältöeditori asiakassivustoille: tekstit ja kuvat muokataan suoraan sivulla",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",