feat: new sharing system

feat: support legacy layout import
This commit is contained in:
2023-09-16 14:17:59 +02:00
parent a39f57bac1
commit 4cd9ce536d
33 changed files with 1391 additions and 698 deletions

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />

View File

@@ -9,6 +9,10 @@ const de = {
DOWNLOAD: "Kopie Speichern",
RESTORE: "Wiederherstellen",
},
share: {
URL_COPIED: "Teilbare URL kopiert!",
EXTRA_DOWNLOAD: "Als Datei herunterladen",
},
profile: {
TITLE: "Profil",
LANGUAGE: "Sprache",

View File

@@ -8,6 +8,10 @@ const en = {
DOWNLOAD: "Download Backup",
RESTORE: "Restore",
},
share: {
URL_COPIED: "Sharable URL copied!",
EXTRA_DOWNLOAD: "Download as file",
},
profile: {
TITLE: "Profile",
LANGUAGE: "Language",

View File

@@ -1,11 +1,10 @@
import type { FormattersInitializer } from 'typesafe-i18n'
import type { Locales, Formatters } from './i18n-types'
import type {FormattersInitializer} from "typesafe-i18n"
import type {Locales, Formatters} from "./i18n-types"
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
const formatters: Formatters = {
// add your formatter functions here
}
const formatters: Formatters = {
// add your formatter functions here
}
return formatters
return formatters
}

View File

@@ -0,0 +1,24 @@
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layout": [
[
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287,
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315,
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 293, 260, 296,
544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278, 357, 516, 519, 517, 518, 327, 336, 338, 335,
337, 328, 325, 322, 323, 324
],
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
]
}

View File

@@ -0,0 +1,270 @@
A1,0,309
A1,1,304
A1,2,312
A1,3,303
A1,4,306
A1,5,290
A1,6,282
A1,7,301
A1,8,266
A1,9,285
A1,10,289
A1,11,270
A1,12,281
A1,13,272
A1,14,262
A1,15,288
A1,16,277
A1,17,298
A1,18,307
A1,19,264
A1,20,287
A1,21,268
A1,22,332
A1,23,311
A1,24,274
A1,25,286
A1,26,308
A1,27,329
A1,28,310
A1,29,280
A1,30,358
A1,31,512
A1,32,515
A1,33,513
A1,34,514
A1,35,313
A1,36,319
A1,37,318
A1,38,321
A1,39,320
A1,40,326
A1,41,315
A1,42,314
A1,43,317
A1,44,316
A1,45,312
A1,46,330
A1,47,331
A1,48,333
A1,49,334
A1,50,291
A1,51,261
A1,52,283
A1,53,536
A1,54,276
A1,55,292
A1,56,265
A1,57,275
A1,58,267
A1,59,263
A1,60,293
A1,61,260
A1,62,296
A1,63,544
A1,64,279
A1,65,294
A1,66,271
A1,67,299
A1,68,269
A1,69,273
A1,70,295
A1,71,284
A1,72,297
A1,73,302
A1,74,278
A1,75,357
A1,76,516
A1,77,519
A1,78,517
A1,79,518
A1,80,327
A1,81,336
A1,82,338
A1,83,335
A1,84,337
A1,85,328
A1,86,325
A1,87,322
A1,88,323
A1,89,324
A2,0,0
A2,1,0
A2,2,0
A2,3,0
A2,4,0
A2,5,0
A2,6,0
A2,7,0
A2,8,0
A2,9,0
A2,10,0
A2,11,0
A2,12,0
A2,13,0
A2,14,0
A2,15,0
A2,16,0
A2,17,0
A2,18,0
A2,19,0
A2,20,0
A2,21,0
A2,22,0
A2,23,0
A2,24,0
A2,25,0
A2,26,0
A2,27,0
A2,28,0
A2,29,0
A2,30,0
A2,31,0
A2,32,0
A2,33,0
A2,34,0
A2,35,0
A2,36,0
A2,37,0
A2,38,0
A2,39,0
A2,40,0
A2,41,0
A2,42,0
A2,43,0
A2,44,0
A2,45,0
A2,46,0
A2,47,0
A2,48,0
A2,49,0
A2,50,0
A2,51,0
A2,52,0
A2,53,0
A2,54,0
A2,55,0
A2,56,0
A2,57,0
A2,58,0
A2,59,0
A2,60,0
A2,61,0
A2,62,0
A2,63,0
A2,64,0
A2,65,0
A2,66,0
A2,67,0
A2,68,0
A2,69,0
A2,70,0
A2,71,0
A2,72,0
A2,73,0
A2,74,0
A2,75,0
A2,76,0
A2,77,0
A2,78,0
A2,79,0
A2,80,0
A2,81,0
A2,82,0
A2,83,0
A2,84,0
A2,85,0
A2,86,0
A2,87,0
A2,88,0
A2,89,0
A3,0,0
A3,1,0
A3,2,0
A3,3,0
A3,4,0
A3,5,0
A3,6,0
A3,7,0
A3,8,0
A3,9,0
A3,10,0
A3,11,0
A3,12,0
A3,13,0
A3,14,0
A3,15,0
A3,16,0
A3,17,0
A3,18,0
A3,19,0
A3,20,0
A3,21,0
A3,22,0
A3,23,0
A3,24,0
A3,25,0
A3,26,0
A3,27,0
A3,28,0
A3,29,0
A3,30,0
A3,31,0
A3,32,0
A3,33,0
A3,34,0
A3,35,0
A3,36,0
A3,37,0
A3,38,0
A3,39,0
A3,40,0
A3,41,0
A3,42,0
A3,43,0
A3,44,0
A3,45,0
A3,46,0
A3,47,0
A3,48,0
A3,49,0
A3,50,0
A3,51,0
A3,52,0
A3,53,0
A3,54,0
A3,55,0
A3,56,0
A3,57,0
A3,58,0
A3,59,0
A3,60,0
A3,61,0
A3,62,0
A3,63,0
A3,64,0
A3,65,0
A3,66,0
A3,67,0
A3,68,0
A3,69,0
A3,70,0
A3,71,0
A3,72,0
A3,73,0
A3,74,0
A3,75,0
A3,76,0
A3,77,0
A3,78,0
A3,79,0
A3,80,0
A3,81,0
A3,82,0
A3,83,0
A3,84,0
A3,85,0
A3,86,0
A3,87,0
A3,88,0
A3,89,0
1 A1 0 309
2 A1 1 304
3 A1 2 312
4 A1 3 303
5 A1 4 306
6 A1 5 290
7 A1 6 282
8 A1 7 301
9 A1 8 266
10 A1 9 285
11 A1 10 289
12 A1 11 270
13 A1 12 281
14 A1 13 272
15 A1 14 262
16 A1 15 288
17 A1 16 277
18 A1 17 298
19 A1 18 307
20 A1 19 264
21 A1 20 287
22 A1 21 268
23 A1 22 332
24 A1 23 311
25 A1 24 274
26 A1 25 286
27 A1 26 308
28 A1 27 329
29 A1 28 310
30 A1 29 280
31 A1 30 358
32 A1 31 512
33 A1 32 515
34 A1 33 513
35 A1 34 514
36 A1 35 313
37 A1 36 319
38 A1 37 318
39 A1 38 321
40 A1 39 320
41 A1 40 326
42 A1 41 315
43 A1 42 314
44 A1 43 317
45 A1 44 316
46 A1 45 312
47 A1 46 330
48 A1 47 331
49 A1 48 333
50 A1 49 334
51 A1 50 291
52 A1 51 261
53 A1 52 283
54 A1 53 536
55 A1 54 276
56 A1 55 292
57 A1 56 265
58 A1 57 275
59 A1 58 267
60 A1 59 263
61 A1 60 293
62 A1 61 260
63 A1 62 296
64 A1 63 544
65 A1 64 279
66 A1 65 294
67 A1 66 271
68 A1 67 299
69 A1 68 269
70 A1 69 273
71 A1 70 295
72 A1 71 284
73 A1 72 297
74 A1 73 302
75 A1 74 278
76 A1 75 357
77 A1 76 516
78 A1 77 519
79 A1 78 517
80 A1 79 518
81 A1 80 327
82 A1 81 336
83 A1 82 338
84 A1 83 335
85 A1 84 337
86 A1 85 328
87 A1 86 325
88 A1 87 322
89 A1 88 323
90 A1 89 324
91 A2 0 0
92 A2 1 0
93 A2 2 0
94 A2 3 0
95 A2 4 0
96 A2 5 0
97 A2 6 0
98 A2 7 0
99 A2 8 0
100 A2 9 0
101 A2 10 0
102 A2 11 0
103 A2 12 0
104 A2 13 0
105 A2 14 0
106 A2 15 0
107 A2 16 0
108 A2 17 0
109 A2 18 0
110 A2 19 0
111 A2 20 0
112 A2 21 0
113 A2 22 0
114 A2 23 0
115 A2 24 0
116 A2 25 0
117 A2 26 0
118 A2 27 0
119 A2 28 0
120 A2 29 0
121 A2 30 0
122 A2 31 0
123 A2 32 0
124 A2 33 0
125 A2 34 0
126 A2 35 0
127 A2 36 0
128 A2 37 0
129 A2 38 0
130 A2 39 0
131 A2 40 0
132 A2 41 0
133 A2 42 0
134 A2 43 0
135 A2 44 0
136 A2 45 0
137 A2 46 0
138 A2 47 0
139 A2 48 0
140 A2 49 0
141 A2 50 0
142 A2 51 0
143 A2 52 0
144 A2 53 0
145 A2 54 0
146 A2 55 0
147 A2 56 0
148 A2 57 0
149 A2 58 0
150 A2 59 0
151 A2 60 0
152 A2 61 0
153 A2 62 0
154 A2 63 0
155 A2 64 0
156 A2 65 0
157 A2 66 0
158 A2 67 0
159 A2 68 0
160 A2 69 0
161 A2 70 0
162 A2 71 0
163 A2 72 0
164 A2 73 0
165 A2 74 0
166 A2 75 0
167 A2 76 0
168 A2 77 0
169 A2 78 0
170 A2 79 0
171 A2 80 0
172 A2 81 0
173 A2 82 0
174 A2 83 0
175 A2 84 0
176 A2 85 0
177 A2 86 0
178 A2 87 0
179 A2 88 0
180 A2 89 0
181 A3 0 0
182 A3 1 0
183 A3 2 0
184 A3 3 0
185 A3 4 0
186 A3 5 0
187 A3 6 0
188 A3 7 0
189 A3 8 0
190 A3 9 0
191 A3 10 0
192 A3 11 0
193 A3 12 0
194 A3 13 0
195 A3 14 0
196 A3 15 0
197 A3 16 0
198 A3 17 0
199 A3 18 0
200 A3 19 0
201 A3 20 0
202 A3 21 0
203 A3 22 0
204 A3 23 0
205 A3 24 0
206 A3 25 0
207 A3 26 0
208 A3 27 0
209 A3 28 0
210 A3 29 0
211 A3 30 0
212 A3 31 0
213 A3 32 0
214 A3 33 0
215 A3 34 0
216 A3 35 0
217 A3 36 0
218 A3 37 0
219 A3 38 0
220 A3 39 0
221 A3 40 0
222 A3 41 0
223 A3 42 0
224 A3 43 0
225 A3 44 0
226 A3 45 0
227 A3 46 0
228 A3 47 0
229 A3 48 0
230 A3 49 0
231 A3 50 0
232 A3 51 0
233 A3 52 0
234 A3 53 0
235 A3 54 0
236 A3 55 0
237 A3 56 0
238 A3 57 0
239 A3 58 0
240 A3 59 0
241 A3 60 0
242 A3 61 0
243 A3 62 0
244 A3 63 0
245 A3 64 0
246 A3 65 0
247 A3 66 0
248 A3 67 0
249 A3 68 0
250 A3 69 0
251 A3 70 0
252 A3 71 0
253 A3 72 0
254 A3 73 0
255 A3 74 0
256 A3 75 0
257 A3 76 0
258 A3 77 0
259 A3 78 0
260 A3 79 0
261 A3 80 0
262 A3 81 0
263 A3 82 0
264 A3 83 0
265 A3 84 0
266 A3 85 0
267 A3 86 0
268 A3 87 0
269 A3 88 0
270 A3 89 0

View File

@@ -0,0 +1,18 @@
import {describe, expect, it} from "vitest"
import legacyLayout from "./legacy-layout.sample.csv?raw"
import legacyLayoutConverted from "./legacy-layout-converted.sample.json"
import {csvLayoutToJson, isCsvLayout} from "./legacy-layout"
describe("legacy layout", () => {
it("should detect a legacy layout", () => {
expect(isCsvLayout(legacyLayout)).to.be.true
})
it("should not detect chord maps as layouts", () => {
expect(isCsvLayout("e + h + t,the")).to.be.false
})
it("should convert legacy layouts", () => {
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted)
})
})

View File

@@ -0,0 +1,25 @@
import type {CharaLayoutFile} from "$lib/share/chara-file"
/**
* Converts a legacy CSV-based layout to the modern JSON-based format
*/
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile {
const layout: CharaLayoutFile = {
charaVersion: 1,
type: "layout",
device,
layout: [[], [], []],
}
for (const layer of csv.split("\n")) {
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)
}
return layout
}
export function isCsvLayout(csv: string): boolean {
return /^(A[123],\d+,\d+\n?)+$/.test(csv)
}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import ActionListItem from "$lib/components/ActionListItem.svelte"
export let exact: number | undefined = undefined

View File

@@ -2,7 +2,7 @@
import {serialLog, serialPort} from "$lib/serial/connection"
import {slide} from "svelte/transition"
function submit(event: InputEvent) {
function submit(event: Event) {
event.preventDefault()
$serialPort.send(value.trim())
value = ""

View File

@@ -23,7 +23,7 @@
results = query ? index.search(searchInput.value) : defaultActions
}
let customValue = undefined
let customValue: number | undefined = undefined
const defaultActions: string[] = [
charaActions,
mouseActions,

View File

@@ -15,7 +15,9 @@
font-family: "Material Symbols Rounded";
font-size: 24px;
font-feature-settings: "liga";
font-variation-settings: "FILL" var(--icon-fill, 0), "wght" var(--icon-weigth, 400),
font-variation-settings:
"FILL" var(--icon-fill, 0),
"wght" var(--icon-weigth, 400),
"GRAD" var(--icon-grade, 0);
font-weight: normal;
font-style: normal;

View File

@@ -13,7 +13,7 @@ export async function toBase64(blob: Blob): Promise<string> {
.replace(/^data:application\/octet-stream;base64,/, "")
.replaceAll("+", ".")
.replaceAll("/", "_")
.replaceAll("=", "-")}-`,
.replaceAll("=", "-")}`,
)
}
reader.readAsDataURL(blob)
@@ -23,7 +23,6 @@ export async function toBase64(blob: Blob): Promise<string> {
export async function fromBase64(base64: string): Promise<Blob> {
return fetch(
`data:application/octet-stream;base64,${base64
.replace(/-$/, "")
.replaceAll(".", "+")
.replaceAll("_", "/")
.replaceAll("-", "=")}`,

View File

@@ -1,6 +1,15 @@
import {compressActions, decompressActions} from "./actions"
import {fromBase64, toBase64} from "$lib/serialization/base64"
export interface NewCharaLayout {
charaLayoutVersion: 1
device: "one" | "lite" | string
/**
* Layers A1-A3, with numeric action codes on each
*/
layers: [number[], number[], number[]]
}
export type CharaLayout = [number[], number[], number[]]
/**

View File

@@ -0,0 +1,52 @@
import {compressActions, decompressActions} from "$lib/serialization/actions"
import {CHARA_FILE_TYPES} from "$lib/share/share-url"
export type ActionArray = number[] | ActionArray[]
export function serializeActionArray(array: ActionArray): Uint8Array {
let out = new Uint8Array(5)
const writer = new DataView(out.buffer)
writer.setUint32(0, array.length)
if (array.length === 0) {
return out
} else if (typeof array[0] === "number") {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number"))
return concatUint8Arrays(out, compressActions(array as number[]))
} else if (Array.isArray(array[0])) {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"))
return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray))
} else {
throw new Error("Not implemented")
}
}
export function deserializeActionArray(raw: Uint8Array): ActionArray {
const reader = new DataView(raw.buffer)
const length = reader.getUint32(0)
const type = CHARA_FILE_TYPES[reader.getUint8(4)]
if (type === "number") {
return decompressActions(raw.slice(5, 5 + length))
} else if (type === "array") {
const innerLength = reader.getUint32(5)
const out = []
let cursor = 5
for (let i = 0; i < length; i++) {
out.push(deserializeActionArray(raw.slice(cursor, cursor + innerLength)))
cursor += innerLength
}
return out
} else {
throw new Error("Not implemented")
}
}
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const out = new Uint8Array(arrays.reduce((a, b) => a + b.length, 0))
let offset = 0
for (const array of arrays) {
out.set(array, offset)
offset += array.length
}
return out
}

View File

@@ -0,0 +1,15 @@
export interface CharaFile<T extends string> {
charaVersion: 1
type: T
}
export interface CharaLayoutFile extends CharaFile<"layout"> {
device: "one" | "lite" | string
layout: [number[], number[], number[]]
}
export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]]
}
export type CharaFiles = CharaLayoutFile | CharaChordFile

View File

@@ -0,0 +1,61 @@
import type {CharaFile, CharaFiles} from "$lib/share/chara-file"
import type {ActionArray} from "$lib/share/action-array"
import {deserializeActionArray, serializeActionArray} from "$lib/share/action-array"
import {fromBase64, toBase64} from "$lib/serialization/base64"
type CharaLayoutOrder = {
[K in CharaFiles["type"]]: Array<
[Exclude<keyof Extract<CharaFiles, {type: K}>, keyof CharaFile<any>>, (typeof CHARA_FILE_TYPES)[number]]
>
}
const keys: CharaLayoutOrder = {
layout: [
["layout", "array"],
["device", "string"],
],
chords: [["chords", "array"]],
}
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const
const sep = "\n"
export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`
for (const [key, type] of keys[file.type]) {
const value = file[key as keyof T]
url += sep
if (type === "string") {
url += value as string
} else if (type === "array") {
const stream = new Blob([serializeActionArray(value as ActionArray)])
.stream()
.pipeThrough(new CompressionStream("deflate"))
url += await toBase64(await new Response(stream).blob())
} else {
throw new Error("Not implemented")
}
}
return url
}
export async function charaFileFromUriComponent<T extends CharaFiles>(uriComponent: string): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep)
const file: any = {type: fileType, version: Number(version)}
for (const [key, type] of keys[fileType as keyof typeof keys]) {
const value = values.pop()!
if (type === "string") {
file[key] = value
} else if (type === "array") {
const stream = (await fromBase64(value)).stream().pipeThrough(new DecompressionStream("deflate"))
const actions = new Uint8Array(await new Response(stream).arrayBuffer())
file[key] = deserializeActionArray(actions)
}
}
return file
}

View File

@@ -57,17 +57,6 @@
gap: 8px;
align-items: center;
list-style: none;
&::before {
content: "";
display: inline-block;
width: 24px;
height: 24px;
background: var(--md-sys-color-on-error);
}
}
dialog::backdrop {

View File

@@ -0,0 +1,6 @@
<script>
import LL from "../../i18n/i18n-svelte"
</script>
<h4>{$LL.share.URL_COPIED()}</h4>
<button>{$LL.share.EXTRA_DOWNLOAD()}</button>

View File

@@ -3,33 +3,70 @@
import {layout} from "$lib/serial/connection"
import tippy from "tippy.js"
import {onMount} from "svelte"
import {layoutAsUrlComponent, layoutFromUrlComponent} from "$lib/serialization/layout"
import Layout from "$lib/components/layout/Layout.svelte"
import {csvLayoutToJson, isCsvLayout} from "$lib/compat/legacy-layout"
import {charaFileFromUriComponent, charaFileToUriComponent} from "$lib/share/share-url"
import type {CharaLayoutFile} from "$lib/share/chara-file"
import SharePopup from "../SharePopup.svelte"
onMount(async () => {
const url = new URL(window.location.href)
if (url.searchParams.has("layout")) {
$layout = await layoutFromUrlComponent(url.searchParams.get("layout")!)
if (url.searchParams.has("import")) {
const file = await charaFileFromUriComponent(url.searchParams.get("import")!)
if (file.type === "layout") {
$layout = file.layout
}
}
})
async function shareLayout(event: Event) {
const url = new URL(window.location.href)
url.searchParams.set("layout", await layoutAsUrlComponent($layout))
url.searchParams.set(
"import",
await charaFileToUriComponent({
charaVersion: 1,
type: "layout",
device: "one",
layout: $layout,
}),
)
await navigator.clipboard.writeText(url.toString())
let shareComponent: SharePopup
tippy(event.target as HTMLElement, {
content: "Share url copied!",
delay: [0, 1000000],
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!
shareComponent = new SharePopup({target})
},
onHidden(instance) {
instance.destroy()
},
onDestroy(instance) {
shareComponent.$destroy()
},
}).show()
}
async function importLayout() {
const file = await fileInput.files?.item(0)?.text()
if (!file) return
const importedLayout = isCsvLayout(file) ? csvLayoutToJson(file) : (JSON.parse(file) as CharaLayoutFile)
if (importedLayout.type === "layout" && importedLayout.charaVersion === 1) $layout = importedLayout.layout
}
let fileInput: HTMLInputElement
</script>
<svelte:window use:share={shareLayout} />
<section>
<label class="icon"
>upload_file<input
bind:this={fileInput}
on:input={importLayout}
type="file"
accept="text/csv, application/json"
/></label
>
<Layout />
</section>
@@ -37,4 +74,8 @@
section {
margin: auto;
}
input[type="file"] {
display: none;
}
</style>