@studiotitta/studio-editori 0.1.4 → 0.1.6

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.
@@ -110,9 +118,9 @@ export interface TekstilistaKentta<K extends boolean = boolean> extends KentanPe
110
118
  * Editorissa se näkyy omana lohkonaan, jotta sivun kentät eivät sekoitu
111
119
  * hakukoneteksteihin.
112
120
  */
113
- export interface RyhmaKentta extends KentanPerus {
121
+ export interface RyhmaKentta<R extends Record<string, Kentta> = Record<string, Kentta>> extends KentanPerus {
114
122
  tyyppi: "ryhma";
115
- kentat: Record<string, Kentta>;
123
+ kentat: R;
116
124
  }
117
125
  /**
118
126
  * Lista samanlaisia kohteita, esimerkiksi kurssit.
@@ -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;
@@ -151,7 +167,7 @@ export declare function kielivalinta(asetukset?: Omit<KielivalintaKentta, "tyypp
151
167
  export declare function tekstilista<K extends boolean = false>(asetukset?: Omit<TekstilistaKentta<K>, "tyyppi" | "kaannettava"> & {
152
168
  kaannettava?: K;
153
169
  }): TekstilistaKentta<K>;
154
- export declare function ryhma(asetukset: Omit<RyhmaKentta, "tyyppi">): RyhmaKentta;
170
+ export declare function ryhma<R extends Record<string, Kentta>>(asetukset: Omit<RyhmaKentta<R>, "tyyppi">): RyhmaKentta<R>;
155
171
  export declare function kohdelista(asetukset: Omit<KohdelistaKentta, "tyyppi">): KohdelistaKentta;
156
172
  export interface SivunMaarittely {
157
173
  /** Nimi, jonka muokkaaja näkee editorissa. Oletuksena sivun avain. */
@@ -193,7 +209,10 @@ export type KentanArvo<K extends Kentta> = K extends {
193
209
  tyyppi: "kohdelista";
194
210
  } ? Record<string, unknown>[] : K extends {
195
211
  tyyppi: "ryhma";
196
- } ? Record<string, unknown> : string;
212
+ kentat: infer R extends Record<string, Kentta>;
213
+ } ? {
214
+ [A in keyof R]: KentanArvo<R[A]>;
215
+ } : string;
197
216
  /** Kentän arvo sellaisena kuin se on tiedostossa. */
198
217
  type TallennettuArvo<K extends Kentta, L extends string> = K extends {
199
218
  kaannettava: true;
@@ -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
  }, []);
@@ -446,6 +464,13 @@ export function Muokkaustila() {
446
464
  }
447
465
  continue;
448
466
  }
467
+ /* Sivun oma ryhmä: kentät ovat ryhmän sisällä, eivät sivun juuressa. */
468
+ if (kenttä.tyyppi === "ryhma") {
469
+ for (const [ryhmanKentta, ryhmanMaaritys] of Object.entries(kenttä.kentat)) {
470
+ tutki({ sivu: sivunAvain, kentta: avain, alikentta: ryhmanKentta }, ryhmanMaaritys);
471
+ }
472
+ continue;
473
+ }
449
474
  tutki({ sivu: sivunAvain, kentta: avain }, kenttä);
450
475
  }
451
476
  return loydot;
@@ -501,14 +526,38 @@ export function Muokkaustila() {
501
526
  muutetut[sivu] = { ...data };
502
527
  }
503
528
  }
529
+ /* Sivulla kirjoitetut luvut tarkistetaan tässä: palvelimelle ei kannata
530
+ lähettää "kymmenen", kun virheen näkee jo selaimessa. */
531
+ const sivunVirheet = [];
504
532
  for (const { elementti, polku } of merkinnat()) {
505
533
  const maaritys = kentanMaaritys(sisalto, polku);
506
534
  const lista = maaritys?.tyyppi === "tekstilista";
507
535
  const teksti = lueTeksti(elementti, lista);
508
536
  if (teksti === alkuperaiset.current.get(elementti))
509
537
  continue;
538
+ let arvo = teksti;
539
+ if (lista && maaritys?.tyyppi === "tekstilista") {
540
+ arvo = jaaKappaleiksi(teksti, maaritys.erotin);
541
+ }
542
+ else if (maaritys?.tyyppi === "luku") {
543
+ const luku = Number(teksti.replace(",", ".").trim());
544
+ if (teksti.trim() === "" || !Number.isFinite(luku)) {
545
+ sivunVirheet.push({
546
+ kohde: maaritys.nimi ?? polku.alikentta ?? polku.kentta,
547
+ viesti: "Kirjoita pelkkä numero.",
548
+ });
549
+ continue;
550
+ }
551
+ arvo = luku;
552
+ }
510
553
  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));
554
+ muutetut[polku.sivu] = asetaArvo(pohja(polku.sivu), polku, arvo, kieli, tunnusKentta(sisalto, polku));
555
+ }
556
+ if (sivunVirheet.length > 0) {
557
+ setTila("muokkaa");
558
+ setViesti("Tarkista sivulla merkityt kohdat. Muutoksesi ovat yhä tässä.");
559
+ setVirheet(sivunVirheet);
560
+ return;
512
561
  }
513
562
  /* Uusi kohde sai tunnuksen "uusi". Nyt kun sillä on nimi, tunnus
514
563
  johdetaan siitä: se näkyy osoitteessa, eikä kukaan halua /uusi-2. */
@@ -614,7 +663,7 @@ export function Muokkaustila() {
614
663
  kohde: kaannettavat
615
664
  .filter(({ polku }) => polku.tunnus !== undefined)
616
665
  .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" }))] })) })] }));
666
+ }, 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
667
  }
619
668
  /** Mitä yhtä kohdetta tässä listassa kutsutaan, esimerkiksi "kurssi". */
620
669
  function kohdelistanYksikko(sisalto, sivu, kentta) {
@@ -635,6 +684,10 @@ function kentanMaaritys(sisalto, polku) {
635
684
  return undefined;
636
685
  if (polku.alikentta === undefined)
637
686
  return kentta;
687
+ /* Ilman tunnusta alikenttä on sivun oman ryhmän kenttä. */
688
+ if (polku.tunnus === undefined) {
689
+ return kentta.tyyppi === "ryhma" ? kentta.kentat[polku.alikentta] : undefined;
690
+ }
638
691
  if (kentta.tyyppi !== "kohdelista")
639
692
  return undefined;
640
693
  const [ensimmainen, ryhmanKentta] = polku.alikentta.split(".");
@@ -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,75 @@ 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 === "ryhma") {
155
+ const ryhmanKentta = osat[2];
156
+ const sisempi = ryhmanKentta ? maaritys.kentat[ryhmanKentta] : undefined;
157
+ return {
158
+ nimi: sisempi?.nimi ?? ryhmanKentta ?? maaritys.nimi ?? kentta,
159
+ sivulla: tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${kentta}.${ryhmanKentta}`),
160
+ };
161
+ }
162
+ if (maaritys.tyyppi !== "kohdelista") {
163
+ return {
164
+ nimi: maaritys.nimi ?? kentta,
165
+ sivulla: tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${kentta}`),
166
+ };
167
+ }
168
+ const tunnus = osat[2];
169
+ const alikentta = osat[3];
170
+ const alimaaritys = alikentta ? maaritys.kentat[alikentta] : undefined;
171
+ let nimi = alimaaritys?.nimi ?? alikentta ?? maaritys.nimi ?? kentta;
172
+ if (alimaaritys?.tyyppi === "ryhma" && osat[4]) {
173
+ const sisempi = alimaaritys.kentat[osat[4]];
174
+ nimi = `${alimaaritys.nimi ?? alikentta}: ${sisempi?.nimi ?? osat[4]}`;
175
+ }
176
+ const kohteet = Array.isArray(tiedot.data[kentta])
177
+ ? tiedot.data[kentta]
178
+ : [];
179
+ const osuma = kohteet.find((yksi) => yksi[maaritys.tunnus] === tunnus);
180
+ return {
181
+ nimi,
182
+ ...(osuma ? { kohteenNimi: kohteenOtsikko(maaritys, osuma, tiedot.kieli) } : {}),
183
+ ...(tunnus === undefined ? {} : { tunnus }),
184
+ sivulla: tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${kentta}[${tunnus}].${alikentta}`),
185
+ };
186
+ }
118
187
  export function Paneeli(tiedot) {
119
188
  const kentat = Object.entries(tiedot.malli.kentat);
120
189
  const lista = kentat.find(([, maaritys]) => maaritys.tyyppi === "kohdelista");
@@ -122,10 +191,20 @@ export function Paneeli(tiedot) {
122
191
  /* Kielivalinta on turha, jos sivustolla on vain yksi kieli. */
123
192
  if (maaritys.tyyppi === "kielivalinta" && tiedot.kielet.length < 2)
124
193
  return false;
194
+ /* Ryhmä, jonka jokainen kenttä muokataan jo sivulla, ei kuulu paneeliin:
195
+ tyhjä otsikko näyttäisi siltä, että jotain puuttuu. */
196
+ if (maaritys.tyyppi === "ryhma") {
197
+ return Object.keys(maaritys.kentat).some((ryhmanKentta) => !tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${avain}.${ryhmanKentta}`));
198
+ }
125
199
  return !tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${avain}`);
126
200
  };
127
201
  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] }));
202
+ 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) => {
203
+ const tiedot_ = virheenKohde(tiedot, virhe.kohde);
204
+ 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}`));
205
+ }) })] })) : null, omat.length > 0 || tiedot.kaannettavia.sivu.length > 0 ? (_jsxs("section", { children: [_jsx("h2", { children: tiedot.malli.nimi }), omat.map(([avain, maaritys]) => maaritys.tyyppi === "ryhma" ? (_jsxs("div", { className: "ryhma", children: [_jsx("h3", { children: maaritys.nimi ?? avain }), maaritys.ohje ? _jsx("p", { className: "ohje", children: maaritys.ohje }) : null, Object.entries(maaritys.kentat)
206
+ .filter(([ryhmanKentta]) => !tiedot.sivullaNakyvat.has(`${tiedot.sivu}.${avain}.${ryhmanKentta}`))
207
+ .map(([ryhmanKentta, ryhmanMaaritys]) => (_jsx(Kentta_, { polku: { sivu: tiedot.sivu, kentta: avain, alikentta: ryhmanKentta }, maaritys: ryhmanMaaritys, tiedot: tiedot }, ryhmanKentta)))] }, avain)) : (_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
208
  }
130
209
  const KIELELLE = {
131
210
  fi: "suomeksi",
@@ -169,20 +248,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
169
248
  const kohteet = Array.isArray(tiedot.data[avain])
170
249
  ? tiedot.data[avain]
171
250
  : [];
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
- };
251
+ const nimi = (kohde) => kohteenOtsikko(maaritys, kohde, tiedot.kieli);
186
252
  /**
187
253
  * Merkinnät kohteen nimen perässä: piilotus, huomionarvoinen tila ja
188
254
  * täyttyminen. Näin listasta näkee tilanteen avaamatta jokaista kohdetta.
@@ -228,7 +294,7 @@ function Kohteet({ avain, maaritys, tiedot, }) {
228
294
  const tunnus = String(kohde[maaritys.tunnus] ?? "");
229
295
  const omatMerkinnat = merkinnat(kohde);
230
296
  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] }));
297
+ }) }), _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
298
  }
233
299
  /**
234
300
  * Kuvakenttä: nykyinen kuva, vaihto ja vaihtoehtoinen teksti.
@@ -242,6 +308,9 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
242
308
  /* Vasta vaihdettu kuva: vanha teksti kuvaa yhä vanhaa kuvaa. */
243
309
  const [vaihdettu, setVaihdettu] = useState(false);
244
310
  const [ehdotus, setEhdotus] = useState(null);
311
+ /* Juuri ladattu kuva näytetään sellaisenaan: tallennettu tiedosto ilmestyy
312
+ osoitteeseensa vasta, kun sivusto on rakennettu uudelleen. */
313
+ const [esikatselu, setEsikatselu] = useState(null);
245
314
  const kuva = onOlio(arvo) ? arvo : undefined;
246
315
  async function valitse(tiedosto) {
247
316
  if (!tiedosto)
@@ -251,9 +320,10 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
251
320
  setEhdotus(null);
252
321
  try {
253
322
  const nykyinenAlt = typeof kuva?.alt === "string" ? kuva.alt.trim() : "";
254
- const uusiEhdotus = await lataa(tiedosto);
323
+ const { ehdotus: uusiEhdotus, esikatselu: uusiKuva } = await lataa(tiedosto);
255
324
  setTila("valmis");
256
325
  setVaihdettu(true);
326
+ setEsikatselu(uusiKuva);
257
327
  /* Jos kentässä oli jo teksti, ehdotusta tarjotaan mutta ei tyrkytetä. */
258
328
  if (uusiEhdotus && nykyinenAlt !== "")
259
329
  setEhdotus(uusiEhdotus);
@@ -263,7 +333,7 @@ function Kuvakentta({ maaritys, arvo, muuta, lataa, }) {
263
333
  setVirhe(syy instanceof Error ? syy.message : "Kuvan lataus ei onnistunut.");
264
334
  }
265
335
  }
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) => {
336
+ 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
337
  setVaihdettu(false);
268
338
  muuta({ ...(kuva ?? {}), alt: tapahtuma.target.value });
269
339
  } })] }), ehdotus ? (_jsxs("div", { className: "huomio", role: "status", children: [_jsxs("p", { children: ["Ehdotus: \u201C", ehdotus, "\u201D"] }), _jsx("button", { type: "button", onClick: () => {
@@ -3,7 +3,13 @@ export interface Kentanpolku {
3
3
  kentta: string;
4
4
  /** Kohteen tunnus, jos kenttä on listan sisällä. */
5
5
  tunnus?: string;
6
- /** Kentän nimi kohteen sisällä. */
6
+ /**
7
+ * Kentän nimi ryhmän sisällä.
8
+ *
9
+ * Ilman tunnusta tämä on sivun oman ryhmän kenttä ("hero.otsikko" →
10
+ * kentta "hero", alikenttä "otsikko"). Tunnuksen kanssa se on kohteen
11
+ * kenttä, joka voi olla myös kohteen ryhmässä ("meta.title").
12
+ */
7
13
  alikentta?: string;
8
14
  }
9
15
  export declare function lueKentanpolku(polku: string): Kentanpolku | null;
@@ -4,6 +4,7 @@
4
4
  Sivulla oleva merkintä kertoo, mitä kohtaa sisällöstä elementti näyttää:
5
5
 
6
6
  kurssit.otsikko sivun oma kenttä
7
+ etusivu.hero.otsikko kenttä sivun ryhmässä
7
8
  kurssit.lista[joogaretriitti].nimi yhden kohteen kenttä listassa
8
9
 
9
10
  Hakasulkeissa on kohteen tunnus, ei järjestysnumero. Näin muokkaus osuu
@@ -13,23 +14,31 @@
13
14
  Tämä tiedosto on tarkoituksella pelkkää logiikkaa ilman selainta, jotta
14
15
  sen voi testata suoraan.
15
16
  ========================================================================== */
16
- /* Alikenttä voi olla ryhmän sisällä: "kurssit.lista[tunnus].meta.title". */
17
- const MUOTO = /^([a-zA-Z][a-zA-Z0-9]*)\.([a-zA-Z][a-zA-Z0-9]*)(?:\[([^\]]+)\]\.([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)?))?$/;
17
+ /* Kolme muotoa: sivun kenttä, sivun ryhmän kenttä ja kohteen kenttä
18
+ listassa. Kohteen kenttä voi olla vielä kohteen oman ryhmän sisällä:
19
+ "kurssit.lista[tunnus].meta.title". */
20
+ const MUOTO = /^([a-zA-Z][a-zA-Z0-9]*)\.([a-zA-Z][a-zA-Z0-9]*)(?:\[([^\]]+)\]\.([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)?)|\.([a-zA-Z][a-zA-Z0-9]*))?$/;
18
21
  export function lueKentanpolku(polku) {
19
22
  const osuma = MUOTO.exec(polku.trim());
20
23
  if (!osuma)
21
24
  return null;
22
- const [, sivu, kentta, tunnus, alikentta] = osuma;
25
+ const [, sivu, kentta, tunnus, kohteenKentta, ryhmanKentta] = osuma;
23
26
  if (!sivu || !kentta)
24
27
  return null;
25
- if (tunnus !== undefined && !alikentta)
26
- return null;
27
- return tunnus === undefined ? { sivu, kentta } : { sivu, kentta, tunnus, alikentta };
28
+ if (tunnus !== undefined) {
29
+ if (!kohteenKentta)
30
+ return null;
31
+ return { sivu, kentta, tunnus, alikentta: kohteenKentta };
32
+ }
33
+ return ryhmanKentta === undefined ? { sivu, kentta } : { sivu, kentta, alikentta: ryhmanKentta };
28
34
  }
29
35
  export function kirjoitaKentanpolku(polku) {
30
- return polku.tunnus === undefined
36
+ if (polku.tunnus !== undefined) {
37
+ return `${polku.sivu}.${polku.kentta}[${polku.tunnus}].${polku.alikentta}`;
38
+ }
39
+ return polku.alikentta === undefined
31
40
  ? `${polku.sivu}.${polku.kentta}`
32
- : `${polku.sivu}.${polku.kentta}[${polku.tunnus}].${polku.alikentta}`;
41
+ : `${polku.sivu}.${polku.kentta}.${polku.alikentta}`;
33
42
  }
34
43
  /* ---- Arvon asettaminen --------------------------------------------------- */
35
44
  function onOlio(arvo) {
@@ -49,7 +58,17 @@ export function asetaArvo(sivunData, polku, arvo, kieli, tunnusKentta = "tunnus"
49
58
  return { ...pohja, [kieli]: arvo };
50
59
  };
51
60
  if (polku.tunnus === undefined) {
52
- return { ...sivunData, [polku.kentta]: yhdista(sivunData[polku.kentta]) };
61
+ if (polku.alikentta === undefined) {
62
+ return { ...sivunData, [polku.kentta]: yhdista(sivunData[polku.kentta]) };
63
+ }
64
+ /* Sivun ryhmä: muut ryhmän kentät säilyvät ennallaan. */
65
+ const ryhma = onOlio(sivunData[polku.kentta])
66
+ ? sivunData[polku.kentta]
67
+ : {};
68
+ return {
69
+ ...sivunData,
70
+ [polku.kentta]: { ...ryhma, [polku.alikentta]: yhdista(ryhma[polku.alikentta]) },
71
+ };
53
72
  }
54
73
  const lista = Array.isArray(sivunData[polku.kentta]) ? [...sivunData[polku.kentta]] : [];
55
74
  const sija = lista.findIndex((kohde) => onOlio(kohde) && kohde[tunnusKentta] === polku.tunnus);
@@ -73,8 +92,12 @@ export function asetaArvo(sivunData, polku, arvo, kieli, tunnusKentta = "tunnus"
73
92
  }
74
93
  /** Lukee yhden arvon sivun sisällöstä. */
75
94
  export function lueArvo(sivunData, polku, tunnusKentta = "tunnus") {
76
- if (polku.tunnus === undefined)
77
- return sivunData[polku.kentta];
95
+ if (polku.tunnus === undefined) {
96
+ const arvo = sivunData[polku.kentta];
97
+ if (polku.alikentta === undefined)
98
+ return arvo;
99
+ return onOlio(arvo) ? arvo[polku.alikentta] : undefined;
100
+ }
78
101
  const lista = sivunData[polku.kentta];
79
102
  if (!Array.isArray(lista))
80
103
  return undefined;
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.6",
4
4
  "description": "Studio Titan sisältöeditori asiakassivustoille: tekstit ja kuvat muokataan suoraan sivulla",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",