evikit 2.1.0 → 2.2.0

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.
Files changed (3) hide show
  1. package/README.md +153 -1
  2. package/package.json +1 -1
  3. package/sqlite.js +4 -1
package/README.md CHANGED
@@ -52,7 +52,7 @@ cd mysite
52
52
  npm init -y
53
53
 
54
54
  # Routing and validation.
55
- npm i accept-language classnames evikit express hoofd preact preact-iso valibot
55
+ npm i accept-language classnames evikit express file-type hoofd preact preact-iso valibot
56
56
  npm i -D @preact/preset-vite cross-env vite vite-bundle-analyzer
57
57
 
58
58
  # API documentation.
@@ -688,6 +688,158 @@ form(
688
688
  ),
689
689
  ```
690
690
 
691
+ ### File upload
692
+
693
+ Why not let each choice have an icon?
694
+
695
+ In `src/lib/db/schema.js`:
696
+
697
+ ```js
698
+ import { instance } from "valibot";
699
+
700
+ // Other imports and Question schema...
701
+
702
+ export const Storage = object({
703
+ content: instance(Uint8Array),
704
+ id: pipe(string(), primary()),
705
+ });
706
+
707
+ export const Choice = object({
708
+ iconStorageId: pipe(string(), references(Storage, "id")),
709
+ // The rest of the Choice schema...
710
+ });
711
+ ```
712
+
713
+ In `src/routes/api/choice/types.js`:
714
+
715
+ ```js
716
+ import { file } from "valibot";
717
+ // Other imports...
718
+
719
+ export const ChoicePost = {
720
+ input: object({
721
+ icon: file(),
722
+ // The rest of the schema...
723
+ ```
724
+
725
+ In `src/routes/api/choice/server.js`:
726
+
727
+ ```js
728
+ import { fileTypeFromBuffer } from "file-type";
729
+ import { createHash } from "node:crypto";
730
+ import { parse, picklist } from "valibot";
731
+
732
+ // ...
733
+ if (!question) {
734
+ return text(i18n.gettext("Question does not exist"), 400);
735
+ }
736
+
737
+ const buffer = Buffer.from(await input.icon.arrayBuffer());
738
+
739
+ // Fail if user sent something else than an image.
740
+ const fileType = await fileTypeFromBuffer(buffer);
741
+ ok(fileType);
742
+ const { ext } = fileType;
743
+ parse(picklist(["gif", "jpg", "png", "webp"]), ext);
744
+
745
+ // Don't save another image if hash already known.
746
+ const iconStorageId = `${createHash("sha256").update(buffer).digest("hex")}.${ext}`;
747
+ db.Storage.upsert({
748
+ content: new Uint8Array(buffer.buffer),
749
+ id: iconStorageId,
750
+ });
751
+
752
+ const [choice] = db.Choice.upsert({
753
+ iconStorageId,
754
+ // ...
755
+ ```
756
+
757
+ In `src/routes/api/question/[id]/types.js`:
758
+
759
+ ```js
760
+ // ...
761
+ question: object({
762
+ ...schema.Question.entries,
763
+ Choice: array(
764
+ object({
765
+ ...schema.Choice.entries,
766
+ iconUrl: string(),
767
+ }),
768
+ ),
769
+ }),
770
+ // ...
771
+ ```
772
+
773
+ In `src/routes/api/question[id]/server.js`:
774
+
775
+ ```js
776
+ import { fileTypeFromBuffer } from "file-type";
777
+ import { ok } from "node:assert/strict";
778
+
779
+ // ...
780
+
781
+ const Question = db.Question.select({
782
+ with: { Choice: { with: { iconStorage: true } } },
783
+ });
784
+
785
+ // ...
786
+
787
+ return json({
788
+ question: {
789
+ ...question,
790
+ Choice: await Promise.all(
791
+ question.Choice.map(async (choice) => {
792
+ ok(choice.iconStorage);
793
+ const buffer = Buffer.from(choice.iconStorage.content.buffer);
794
+ const fileType = await fileTypeFromBuffer(buffer);
795
+ ok(fileType);
796
+ return {
797
+ ...choice,
798
+ iconUrl: `data:${fileType.mime};base64,${buffer.toString("base64")}`,
799
+ };
800
+ }),
801
+ ),
802
+ },
803
+ });
804
+ ```
805
+
806
+ In `src/routes/question/[id]/page.js`:
807
+
808
+ ```js
809
+ import { img } from "evikit";
810
+ // ...
811
+
812
+ form(
813
+ {
814
+ action: "/api/choice",
815
+ enctype: "multipart/form-data",
816
+ method: "POST",
817
+ onSubmit: suggestPost.onSubmit,
818
+ },
819
+ // ...
820
+ p(
821
+ { class: "fieldset" },
822
+ label({ for: "icon" }, i18n.gettext("Icon")),
823
+ input({ id: "icon", name: "icon", type: "file" }),
824
+ ),
825
+ // The rest of the /api/choice form...
826
+ ),
827
+
828
+ form(
829
+ { action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
830
+ // ...
831
+ input({
832
+ id: choice.id,
833
+ name: "choiceId",
834
+ type: "radio",
835
+ value: choice.id,
836
+ }),
837
+ label(
838
+ { for: choice.id },
839
+ img({ alt: "", height: "24", src: choice.iconUrl, width: "24" }),
840
+ // ...
841
+ ```
842
+
691
843
  ### Translation
692
844
 
693
845
  For the Ukrainian language, which has the `uk` [two-letter language code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes), create an empty file `po/uk.po`. It will use the [gettext](https://www.gnu.org/software/gettext/) format, which is well-known to professional translators.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "evikit",
3
3
  "description": "Preact/SQLite SSR framework for small web apps",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
package/sqlite.js CHANGED
@@ -71,7 +71,10 @@ export function sqlite({ pkg, schema }) {
71
71
  : entry.expects == "number" ||
72
72
  entry.expects == "(number | undefined)"
73
73
  ? "INTEGER"
74
- : fail(name),
74
+ : entry.expects == "Uint8Array" ||
75
+ entry.expects == "(Uint8Array | undefined)"
76
+ ? "BLOB"
77
+ : fail(name),
75
78
  };
76
79
  }),
77
80
  foreignKeys: Object.entries(entries)