@studiotitta/studio-editori 0.1.4 → 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;
@@ -85,15 +85,15 @@ function Kentta_({ polku, maaritys, tiedot, }) {
85
85
  const tunniste = `kentta-${polku.kentta}-${polku.tunnus ?? ""}-${(polku.alikentta ?? "").replace(".", "-")}`;
86
86
  const kaannettava = "kaannettava" in maaritys && maaritys.kaannettava === true;
87
87
  const arvo = nykyinenArvo(maaritys, lueArvo(tiedot.data, polku, tunnuksenNimi(tiedot, polku.kentta)), tiedot.kieli);
88
- 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"
89
89
  ? async (tiedosto) => {
90
- const { src, ehdotus } = await tiedot.lataaKuva(tiedosto, maaritys);
90
+ const { src, ehdotus, esikatselu } = await tiedot.lataaKuva(tiedosto, maaritys);
91
91
  const vanha = onOlio(arvo) ? arvo : {};
92
92
  /* Tyhjä teksti täytetään ehdotuksella suoraan; olemassa
93
93
  olevaa ei ylikirjoiteta, vaan ehdotus tarjotaan. */
94
94
  const alt = typeof vanha.alt === "string" ? vanha.alt.trim() : "";
95
95
  tiedot.muuta(polku, { ...vanha, src, ...(alt === "" && ehdotus ? { alt: ehdotus } : {}) }, false);
96
- return ehdotus;
96
+ return { ehdotus, esikatselu };
97
97
  }
98
98
  : undefined }), _jsx(Vapaana, { polku: polku, maaritys: maaritys, arvo: arvo, tiedot: tiedot }), maaritys.ohje ? _jsx("p", { className: "ohje", children: maaritys.ohje }) : null] }));
99
99
  }
@@ -115,6 +115,67 @@ function Vapaana({ polku, maaritys, arvo, tiedot, }) {
115
115
  const vapaana = Math.max(0, yla - arvo);
116
116
  return (_jsx("p", { className: "laskuri", role: "status", children: vapaana === 0 ? "Täynnä" : `Vapaana ${vapaana} / ${yla} paikkaa` }));
117
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
+ }
118
179
  export function Paneeli(tiedot) {
119
180
  const kentat = Object.entries(tiedot.malli.kentat);
120
181
  const lista = kentat.find(([, maaritys]) => maaritys.tyyppi === "kohdelista");
@@ -125,7 +186,10 @@ export function Paneeli(tiedot) {
125
186
  return !tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${avain}`);
126
187
  };
127
188
  const omat = kentat.filter(([avain, maaritys]) => maaritys.tyyppi !== "kohdelista" && nayta([avain, maaritys]));
128
- 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] }));
129
193
  }
130
194
  const KIELELLE = {
131
195
  fi: "suomeksi",
@@ -169,20 +233,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
169
233
  const kohteet = Array.isArray(tiedot.data[avain])
170
234
  ? tiedot.data[avain]
171
235
  : [];
172
- const nimi = (kohde) => {
173
- const arvo = kohde[maaritys.otsikko];
174
- if (typeof arvo === "string")
175
- return arvo === "" ? "(nimetön)" : arvo;
176
- if (!onOlio(arvo))
177
- return "(nimetön)";
178
- /* Nykyinen kieli ensin, mutta kääntämätön kurssi näkyy listassa sillä
179
- kielellä, jolla se on kirjoitettu — ei nimettömänä. */
180
- const omalla = arvo[tiedot.kieli];
181
- if (typeof omalla === "string" && omalla !== "")
182
- return omalla;
183
- const joku = Object.values(arvo).find((muu) => typeof muu === "string" && muu !== "");
184
- return joku ?? "(nimetön)";
185
- };
236
+ const nimi = (kohde) => kohteenOtsikko(maaritys, kohde, tiedot.kieli);
186
237
  /**
187
238
  * Merkinnät kohteen nimen perässä: piilotus, huomionarvoinen tila ja
188
239
  * täyttyminen. Näin listasta näkee tilanteen avaamatta jokaista kohdetta.
@@ -228,7 +279,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
228
279
  const tunnus = String(kohde[maaritys.tunnus] ?? "");
229
280
  const omatMerkinnat = merkinnat(kohde);
230
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));
231
- }) }), _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] }));
232
283
  }
233
284
  /**
234
285
  * Kuvakenttä: nykyinen kuva, vaihto ja vaihtoehtoinen teksti.
@@ -242,6 +293,9 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
242
293
  /* Vasta vaihdettu kuva: vanha teksti kuvaa yhä vanhaa kuvaa. */
243
294
  const [vaihdettu, setVaihdettu] = useState(false);
244
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);
245
299
  const kuva = onOlio(arvo) ? arvo : undefined;
246
300
  async function valitse(tiedosto) {
247
301
  if (!tiedosto)
@@ -251,9 +305,10 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
251
305
  setEhdotus(null);
252
306
  try {
253
307
  const nykyinenAlt = typeof kuva?.alt === "string" ? kuva.alt.trim() : "";
254
- const uusiEhdotus = await lataa(tiedosto);
308
+ const { ehdotus: uusiEhdotus, esikatselu: uusiKuva } = await lataa(tiedosto);
255
309
  setTila("valmis");
256
310
  setVaihdettu(true);
311
+ setEsikatselu(uusiKuva);
257
312
  /* Jos kentässä oli jo teksti, ehdotusta tarjotaan mutta ei tyrkytetä. */
258
313
  if (uusiEhdotus && nykyinenAlt !== "")
259
314
  setEhdotus(uusiEhdotus);
@@ -263,7 +318,7 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
263
318
  setVirhe(syy instanceof Error ? syy.message : "Kuvan lataus ei onnistunut.");
264
319
  }
265
320
  }
266
- 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) => {
267
322
  setVaihdettu(false);
268
323
  muuta({ ...(kuva ?? {}), alt: tapahtuma.target.value });
269
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.4",
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",