Selaa lähdekoodia

Merge branch 'codes' of gogsadmin/home-launcher into master

gogsadmin 2 kuukautta sitten
vanhempi
säilyke
3179a7dd39
85 muutettua tiedostoa jossa 1459 lisäystä ja 469 poistoa
  1. 2 1
      .dockerignore
  2. 1 1
      .gitignore
  3. 1 3
      .gitmodules
  4. 6 0
      activities/sqlpage/migrations/001_activities.sql
  5. 5 0
      activities/sqlpage/sqlpage.json
  6. 114 0
      activities/www/chart.sql
  7. 93 0
      activities/www/index.sql
  8. 14 0
      css/clip.css
  9. 20 0
      css/code.css
  10. 9 0
      css/goto.css
  11. 9 0
      css/upload.css
  12. 38 22
      docker-compose.yml
  13. 1 1
      grocery-transactions
  14. 2 1
      home-sqlpage.json
  15. 57 10
      home-sqlpage/index.sql
  16. 72 34
      home-sqlpage/sqlpage/migrations/000_index.sql
  17. 14 0
      rest/bar.py
  18. 109 75
      rest/pyapi.py
  19. 9 7
      rest/qr.py
  20. 1 0
      rest/requirements.txt
  21. 9 1
      rest/static/clip/clip-favicon_square.svg
  22. 1 1
      rest/static/clip/manifest.json
  23. 1 0
      rest/static/code/qr.svg
  24. 2 2
      rest/static/goto/chain-link2fr-3-2.svg
  25. 1 1
      rest/static/goto/manifest.json
  26. BIN
      rest/static/shandanone-small.png
  27. 68 0
      rest/static/shandanone-small.svg
  28. BIN
      rest/static/shandanone2.png
  29. 78 0
      rest/static/shandanone2.svg
  30. 1 1
      rest/static/upload/manifest.json
  31. 0 0
      rest/static/upload/qr.svg
  32. 1 1
      rest/static/upload/upload-favicon_maskable.svg
  33. 6 3
      rest/static/upload/upload-favicon_square.svg
  34. 3 1
      util-sqlpage.json
  35. 0 3
      util-sqlpage/clip.sql
  36. 5 6
      util-sqlpage/clip/Index.sql
  37. 0 5
      util-sqlpage/clip/download.sql
  38. 0 9
      util-sqlpage/clip/entry.sql
  39. 21 25
      util-sqlpage/clip/form.sql
  40. 28 0
      util-sqlpage/clip/index.sql
  41. 0 2
      util-sqlpage/clip/open.sql
  42. 4 22
      util-sqlpage/clip/save.sql
  43. 11 0
      util-sqlpage/code/Index.sql
  44. 67 0
      util-sqlpage/code/form-fuel.sql
  45. 59 0
      util-sqlpage/code/form.sql
  46. 16 0
      util-sqlpage/code/index.sql
  47. 24 0
      util-sqlpage/code/json/filters.json
  48. 9 0
      util-sqlpage/code/new.sql
  49. 52 0
      util-sqlpage/code/recent.sql
  50. 12 0
      util-sqlpage/code/save.sql
  51. 12 0
      util-sqlpage/code/update.sql
  52. 0 11
      util-sqlpage/goto.sql
  53. 5 6
      util-sqlpage/goto/Index.sql
  54. 0 10
      util-sqlpage/goto/entry.sql
  55. 10 12
      util-sqlpage/goto/form.sql
  56. 32 0
      util-sqlpage/goto/index.sql
  57. 0 2
      util-sqlpage/goto/open.sql
  58. 9 12
      util-sqlpage/goto/preview.sql
  59. 7 8
      util-sqlpage/goto/redirect.sql
  60. 6 23
      util-sqlpage/goto/save.sql
  61. 9 6
      util-sqlpage/sqlpage/Link.sql
  62. 4 5
      util-sqlpage/sqlpage/Open.sql
  63. 2 2
      util-sqlpage/sqlpage/QR.sql
  64. 43 12
      util-sqlpage/sqlpage/Style.sql
  65. 4 4
      util-sqlpage/sqlpage/link.sql
  66. 7 0
      util-sqlpage/sqlpage/migrations/006_code.sql
  67. 9 0
      util-sqlpage/sqlpage/migrations/007_code_detail.sql
  68. 6 0
      util-sqlpage/sqlpage/migrations/008_upload_temp.sql
  69. 1 0
      util-sqlpage/sqlpage/migrations/009_goto.sql
  70. 17 0
      util-sqlpage/sqlpage/save.sql
  71. 6 0
      util-sqlpage/sqlpage/templates/loader-start.handlebars
  72. 3 0
      util-sqlpage/sqlpage/templates/loader-stop.handlebars
  73. 42 0
      util-sqlpage/sqlpage/templates/progress.handlebars
  74. 2 0
      util-sqlpage/sqlpage/templates/spinner-start.handlebars
  75. 2 0
      util-sqlpage/sqlpage/templates/spinner-stop.handlebars
  76. 42 4
      util-sqlpage/sqlpage/theme.sql
  77. 38 34
      util-sqlpage/sqlpage/validate.sql
  78. 0 6
      util-sqlpage/upload.sql
  79. 5 11
      util-sqlpage/upload/Index.sql
  80. 0 9
      util-sqlpage/upload/entry.sql
  81. 21 30
      util-sqlpage/upload/form.sql
  82. 43 0
      util-sqlpage/upload/index.sql
  83. 0 2
      util-sqlpage/upload/open.sql
  84. 13 22
      util-sqlpage/upload/save.sql
  85. 3 0
      util-sqlpage/upload/temp.sql

+ 2 - 1
.dockerignore

@@ -1,3 +1,4 @@
+.git/
 feed/
 media/
 prayer-generator
@@ -5,4 +6,4 @@ prayer-generator
 !prayer-generator/license
 grocery-transactions
 files/
-
+*.db

+ 1 - 1
.gitignore

@@ -2,4 +2,4 @@ media/
 .env
 __pycache__/
 files/
-.db
+*.db

+ 1 - 3
.gitmodules

@@ -6,6 +6,4 @@
 [submodule "grocery-transactions"]
 	path = grocery-transactions
 	url = https://gogs.shandan.one/gogsadmin/grocery-manager.git
-	#branch = vertical-button-groups
-	#branch = master
-        branch = sqlpage
+        branch = master

+ 6 - 0
activities/sqlpage/migrations/001_activities.sql

@@ -0,0 +1,6 @@
+DROP TABLE IF EXISTS activities;
+CREATE TABLE activities (
+  user TEXT,
+  ts TIMESTAMP,
+  activity TEXT
+);

+ 5 - 0
activities/sqlpage/sqlpage.json

@@ -0,0 +1,5 @@
+{
+  "database_url": "sqlite://./activities.db",
+  "site_prefix": "/activity",
+  "max_database_pool_connections": 16
+}

+ 114 - 0
activities/www/chart.sql

@@ -0,0 +1,114 @@
+SET ":user" = COALESCE(:user, $user, '');
+SET ":date" = COALESCE(:date, $date, CURRENT_TIMESTAMP);
+SET ":start_of_day" = (SELECT datetime(datetime(:date, 'localtime'), 'start of day'));
+SET ":end_of_day" = (SELECT datetime(:start_of_day, '+1 days'));
+
+SET ":total_time" = (WITH delta AS (SELECT julianday(COALESCE((
+  SELECT min(lag.ts, datetime(:end_of_day, 'utc'))
+  FROM activities lag
+  WHERE lag.ts > a.ts AND lag.activity <> a.activity
+  ORDER BY lag.ts ASC
+  LIMIT 1
+), min(CURRENT_TIMESTAMP, datetime(:end_of_day, 'utc')))) - julianday(a.ts) AS delta
+, activity
+FROM activities a
+WHERE user = :user
+AND datetime(datetime(ts, 'localtime'), 'start of day') = :start_of_day)
+SELECT printf("%.0f", sum(delta)*60*24) AS minutes --, activity
+FROM delta
+WHERE activity = 'Eating'
+GROUP BY delta.activity);
+
+SELECT 'chart' AS component
+--, '' AS title
+, :total_time || ' min.' AS title
+, TRUE AS time
+, 'area' AS type
+, 1 AS ymax
+, 0 AS ymin
+, 1 AS ystep
+, '100' AS height
+-- TODO: fix color to series
+--
+--  SELECT DISTINCT color(activity) FROM activities ORDER BY activity;
+, 'azure' AS color
+, 'green' AS color
+, 'red' AS color
+;
+SET ":any" = EXISTS (SELECT * FROM activities
+    WHERE user = :user
+    AND datetime(ts, 'localtime')
+    BETWEEN :start_of_day AND :end_of_day
+    LIMIT 1
+);
+SELECT NULL AS x, NULL AS y, 'Unknown' AS series WHERE :any;
+SELECT NULL AS x, NULL AS y, 'Eating' AS series WHERE :any;
+SELECT NULL AS x, NULL AS y, 'Not eating' AS series WHERE :any;
+SET ":morning_offset" = '08:00:00.000';
+SET ":evening_offset" = '03:00:00.000';
+WITH plot AS (
+    SELECT ROW_NUMBER() OVER (ORDER BY ts) AS row
+    , ts
+    , activity
+    FROM (
+        SELECT ts, activity FROM activities
+        WHERE user = :user
+        AND datetime(ts, 'localtime') BETWEEN :start_of_day AND :end_of_day
+        UNION
+        SELECT datetime(:start_of_day, 'utc'), (
+            SELECT activity FROM activities
+            WHERE user = :user AND datetime(ts, 'localtime') <= :start_of_day
+            AND :any
+            ORDER BY ts DESC
+            LIMIT 1
+        )
+        UNION
+        SELECT datetime(:end_of_day, 'utc'), (
+            SELECT activity FROM activities
+            WHERE user = :user AND datetime(ts, 'localtime') <= :end_of_day
+            AND :any
+            ORDER BY ts DESC
+            LIMIT 1
+        )
+    )
+)
+SELECT datetime(:start_of_day, '+'||:morning_offset) AS x, 1 AS y, (
+    SELECT activity FROM activities
+    WHERE user = :user AND datetime(ts, 'localtime') <= datetime(:start_of_day, '+'||:morning_offset)
+    AND :any
+    ORDER BY ts DESC
+    LIMIT 1
+) AS series
+UNION
+SELECT datetime(:end_of_day, '-'||:evening_offset) AS x, 1 AS y, (
+    SELECT activity FROM activities
+    WHERE user = :user AND datetime(ts, 'localtime') <= datetime(:end_of_day, '-'||:evening_offset)
+    AND :any
+    ORDER BY ts DESC
+    LIMIT 1
+) AS series
+UNION
+SELECT datetime(x, 'localtime') AS x, CASE value WHEN 0 THEN NULL ELSE value END AS y, series FROM (
+    SELECT datetime(next.ts, '-00:00:01.000') AS x, 0 AS value, next.activity AS series
+    FROM plot, plot next
+    WHERE plot.row = next.row - 1
+    AND plot.activity <> next.activity
+    UNION
+    SELECT ts AS x, 1 AS value, activity AS series
+    FROM plot
+    UNION
+    SELECT datetime(next.ts, '-00:00:01.000') AS x, 1 AS value, plot.activity AS series
+    FROM plot, plot next
+    WHERE plot.row = next.row - 1
+    AND plot.activity <> next.activity
+    UNION
+    SELECT next.ts AS x, 0 AS value, plot.activity AS series
+    FROM plot, plot next
+    WHERE plot.row = next.row - 1
+    AND plot.activity <> next.activity
+) q
+WHERE
+    datetime(datetime(x, 'localtime'), '-'||:morning_offset) >= :start_of_day
+AND
+    datetime(datetime(x, 'localtime'), '+'||:evening_offset) <= :end_of_day
+;

+ 93 - 0
activities/www/index.sql

@@ -0,0 +1,93 @@
+SET ":user" = 'shannon';
+INSERT INTO activities(user, activity, ts)
+SELECT :user, :activity, COALESCE(CASE COALESCE(:ts, '')
+  WHEN '' THEN NULL
+  ELSE datetime(:ts, 'utc')
+END, CURRENT_TIMESTAMP)
+WHERE COALESCE(:activity, '') <> ''
+RETURNING 'redirect' AS component, '/activity' AS link
+;
+
+SET ":title" = 'Activities';
+SELECT 'shell' AS component
+, 'dark' AS theme
+, :title AS title
+, 'fluid' AS layout
+, '/activity' AS link
+;
+
+SELECT 'text' AS component
+, '<style>
+.card > .card-body:has(.card-body > .d-flex + .chart) {
+    margin-top: 0 !important;
+    padding: 0;
+}
+.card > .card-body:has(.card-body > .d-flex + .chart) > .card-title {
+    position: absolute;
+    z-index: 99;
+    margin: 0;
+    padding: 0 10px;
+}
+.card > .card-body:has(> .d-flex + .chart) {
+    padding: 0;
+}
+.card > .card-body > .d-flex {
+    font-size: 0.3em;
+    padding: 0 120px;
+}
+.card > .card-body > .d-flex {
+    height: 0;
+}
+.col:has(+ .col .apexcharts-legend-text) .card > .card-body:has(> .d-flex + .chart) {
+    max-height: 80px;
+}
+.apexcharts-yaxis {
+    display: none;
+}
+.col:has(+ .col .apexcharts-legend-text) .apexcharts-legend {
+    /* height: 0; // modifying the height alters the chart size */
+    visibility: hidden;
+}
+</style>' AS html
+;
+
+SELECT 'form' AS component
+, '/activity/index.sql' AS action
+;
+SELECT 'select' AS type
+, 'Activity' AS label
+, 'activity' AS name
+, TRUE AS create_new
+, TRUE AS dropdown
+, (SELECT json_group_array(json_object(
+    'label', activity,
+    'value', activity,
+    'selected', activity = 'Unknown'
+  )) FROM activities WHERE user = :user
+) AS options
+, 7 AS width
+;
+SELECT 'datetime-local' AS type
+, 'ts' AS name
+, 'Date and Time' AS label
+, '' AS value
+, 4 AS width
+;
+
+SELECT 'button' AS component
+SELECT 'Week' AS title
+, 1 AS width
+, '/activity?week=week' AS link
+;
+
+SELECT 'card' AS component
+, 1 AS columns
+;
+
+SELECT date(datetime(CURRENT_TIMESTAMP, '-'|| value ||'  days'), 'localtime') AS title
+, '/activity/chart.sql'||
+'?user='||:user||
+'&date='|| REPLACE(datetime(CURRENT_TIMESTAMP, '-'|| value ||'  days'), ' ', '+') ||
+'&_sqlpage_embed' AS embed
+FROM json_each(json(CASE $week WHEN 'week' THEN '[0, 1, 2, 3, 4, 5, 6, 7]' ELSE '[0, 1]' END))
+;

+ 14 - 0
css/clip.css

@@ -0,0 +1,14 @@
+body .pure-g {
+    text-align: center
+}
+details svg, details img {
+    background-color: white
+}
+textarea.form-control {
+  font-family: FreeMono, monospace;
+}
+textarea.form-control[disabled] {
+  background-color: var(--tblr-bg-forms);
+  color: var(--tblr-body-color);
+  border-color: #2fb344;
+}

+ 20 - 0
css/code.css

@@ -0,0 +1,20 @@
+body .pure-g {
+    text-align: center
+}
+details svg, details img {
+    background-color: white
+}
+.row-cols-lg-2:has(.bg-azure) {
+  justify-content: center;
+}
+.card-img-top {
+    width: 90%;
+    margin: 5%;
+}
+table td, table th {
+   padding-left: 5px;
+   padding-right: 5px;
+   padding-top: 2px;
+   padding-bottom: 2px;
+   border-width: 1px;
+}

+ 9 - 0
css/goto.css

@@ -0,0 +1,9 @@
+body .pure-g {
+    text-align: center
+}
+details svg, details img {
+    background-color: white
+}
+.row-cols-lg-2:has(.bg-google) {
+  justify-content: center;
+}

+ 9 - 0
css/upload.css

@@ -0,0 +1,9 @@
+body .pure-g {
+    text-align: center
+}
+details svg, details img {
+    background-color: white
+}
+.row-cols-lg-2:has(.bg-yellow) {
+  justify-content: center;
+}

+ 38 - 22
docker-compose.yml

@@ -1,17 +1,48 @@
 services:
+  activities:
+    image: lovasoa/sqlpage:latest
+    hostname: activities
+    volumes:
+      - /etc/timezone:/etc/timezone:ro
+      - /etc/localtime:/etc/localtime:ro
+      - ./activities/www/:/var/www/
+      - ./activities/sqlpage:/etc/sqlpage/
+      - ./activities/activities.db:/var/www/activities.db
+    networks:
+      - traefik
+    restart: always
+
   util-sqlpage:
-    image: lovasoa/sqlpage
+    image: lovasoa/sqlpage:latest
     hostname: util-sqlpage
     volumes:
+      - /etc/timezone:/etc/timezone:ro
+      - /etc/localtime:/etc/localtime:ro
       - ./util-sqlpage/:/var/www/
       - ./util-sqlpage.json:/etc/sqlpage/sqlpage.json
       - ./util-sqlpage/sqlpage/migrations:/etc/sqlpage/migrations
+      # spinner support
+      - ./util-sqlpage/sqlpage/templates:/etc/sqlpage/templates
+    networks:
+      - traefik
+    restart: always
+
+  util-pyapi:
+    image: util-pyapi
+    hostname: util-pyapi
+    build:
+      context: .
+      dockerfile: rest/Dockerfile
+    volumes:
+      - ./util-sqlpage/util.db:/usr/src/app/util.db:ro
+    expose:
+      - 6772
     networks:
       - traefik
     restart: always
 
   home-sqlpage:
-    image: lovasoa/sqlpage
+    image: lovasoa/sqlpage:latest
     hostname: home-sqlpage
     volumes:
       - ./home-sqlpage/:/var/www/
@@ -54,13 +85,13 @@ services:
       - traefik
     restart: always
 
-  grocery-pyapi:
-    image: lovasoa/sqlpage
-    hostname: grocery-pyapi
+  grocery:
+    image: lovasoa/sqlpage:latest
+    hostname: grocery-sqlpage
     volumes:
       - ./grocery-transactions/sqlpage:/var/www/
-      - ./grocery-transactions/sqlpage/sqlpage/migrations:/etc/sqlpage/migrations
-      - ./sqlpage.json:/etc/sqlpage/sqlpage.json
+      - ./grocery-transactions/sqlpage/sqlpage:/etc/sqlpage:ro
+      - ./grocery-transactions/sqlpage/sqlpage/sqlpage.json:/etc/sqlpage/sqlpage.json:ro
     expose:
       - 6772
     networks:
@@ -82,21 +113,6 @@ services:
       - traefik
     restart: always
 
-  util-pyapi:
-    image: util-pyapi
-    hostname: util-pyapi
-    build:
-      context: .
-      dockerfile: rest/Dockerfile
-    volumes:
-      - ./util-sqlpage/util.db:/usr/src/app/util.db:ro
-      - ./files:/usr/src/app/rest/static/files
-    expose:
-      - 6772
-    networks:
-      - traefik
-    restart: always
-
 networks:
   traefik:
     external: true

+ 1 - 1
grocery-transactions

@@ -1 +1 @@
-Subproject commit 38c27f9ff91537b79c81918d37756cd536754a68
+Subproject commit 1be719a75116c9c52a85d203d29511f457f6c427

+ 2 - 1
home-sqlpage.json

@@ -1,4 +1,5 @@
 {
   "max_database_pool_connections": 16,
-  "database_url": "sqlite://:memory:"
+  "database_url": "sqlite://:memory:",
+  "compress_responses": false
 }

+ 57 - 10
home-sqlpage/index.sql

@@ -1,7 +1,43 @@
 SELECT 'shell' AS component
 , 'dark' AS theme
 , 'Portal' AS title
+, '/static/shandanone-small.png' AS favicon
+, '/static/shandanone2.png' AS image
+, (SELECT json_object(
+    'title', 'Utilities',
+    'icon', 'calculator',
+    'submenu', json_group_array(json_object(
+      'title', title,
+      'icon', icon,
+      'link', link
+    ))
+  )
+  FROM sqlpage_cards
+  WHERE grouping = 2) AS menu_item
+, (SELECT json_object(
+    'title', 'Apps',
+    'icon', 'apps',
+    'submenu', json_group_array(json_object(
+      'title', title,
+      'icon', icon,
+      'link', link
+    ))
+  )
+  FROM sqlpage_cards
+  WHERE grouping = 3) AS menu_item
+, (SELECT json_object(
+    'title', 'RSS',
+    'icon', 'rss',
+    'submenu', json_group_array(json_object(
+      'title', title,
+      'icon', 'file-rss',
+      'link', link
+    ))
+  )
+  FROM sqlpage_cards
+  WHERE grouping = 4) AS menu_item
 ;
+
 SELECT 'text' AS component
 , '<style>
 .card-img-top:has(+ .bg-rss) {
@@ -12,13 +48,20 @@ SELECT 'text' AS component
     width: 40%;
     margin: 10% 30% 10% 30%;
 }
+/* make row-cols-sm-2 behave like row-cols-2 */
+@media (max-width: 500px) {
+  .row-cols-sm-2>* {
+    flex: 0 0 auto;
+    width: 50%
+  }
+}
 </style>' AS html
 ;
 SELECT 'card' AS component
 , 4 AS columns
 ;
-SELECT 'https://shandan.one'||link AS link
-, title
+SELECT title
+, link
 , top_image
 , description_md
 , color
@@ -29,19 +72,23 @@ SELECT 'divider' AS component;
 SELECT 'card' AS component
 , 4 AS columns
 ;
-SELECT 'https://shandan.one/feed'||link AS link
-, title
-, 'https://upload.wikimedia.org/wikipedia/en/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png' AS top_image
+SELECT title
+, link
+, top_image
 , description_md
-, 'rss' AS color
+, color
 FROM sqlpage_cards
-WHERE grouping = 1
+WHERE grouping = 3
 ;
 SELECT 'divider' AS component;
 SELECT 'card' AS component
 , 4 AS columns
 ;
-SELECT * FROM sqlpage_cards
-WHERE grouping = 3
+SELECT title
+, link
+, 'https://upload.wikimedia.org/wikipedia/en/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png' AS top_image
+, description_md
+, 'rss' AS color
+FROM sqlpage_cards
+WHERE grouping = 4
 ;
-

+ 72 - 34
home-sqlpage/sqlpage/migrations/000_index.sql

@@ -5,33 +5,37 @@ CREATE TABLE IF NOT EXISTS sqlpage_cards (
 	top_image text,
 	description_md text,
 	color text,
-	grouping int
+	grouping int,
+	icon text
 );
 INSERT INTO sqlpage_cards
 VALUES
 (
-	'/news/rss.html',
+	'/feed/news/rss.html',
 	'News Feed',
-	'',
+	NULL,
 	'Notable events but not weather or sports',
-	'',
-	1
+	NULL,
+	4,
+	NULL
 ),
 (
-	'/discovery/rss.html',
+	'/feed/discovery/rss.html',
 	'Discovery Feed',
-	'',
+	NULL,
 	'Cool and inspirational articles',
-	'',
-	1
+	NULL,
+	4,
+	NULL
 ),
 (
-	'/humour/rss.html',
+	'/feed/humour/rss.html',
 	'Humour Feed',
-	'',
+	NULL,
 	'Humourous articles, memes or anything funny',
-	'rss',
-	1
+	NULL,
+	4,
+	NULL
 ),
 (
 	'/clip?',
@@ -39,7 +43,8 @@ VALUES
 	'https://shandan.one/static/clip/clip-favicon_square.svg',
 	'Paste snippets to share via tiny URL',
 	'green',
-	2
+	2,
+	'clipboard'
 ),
 (
 	'/goto?',
@@ -47,7 +52,8 @@ VALUES
 	'https://shandan.one/static/goto/chain-link2fr-3-2.svg',
 	'Make long URLs shorter and easy to type and say',
 	'pinterest',
-	2
+	2,
+	'link'
 ),
 (
 	'/upload?',
@@ -55,15 +61,26 @@ VALUES
 	'https://shandan.one/static/upload/upload-favicon_square.svg',
 	'Upload a file and generate a short URL to share easily',
 	'yellow',
-	2
+	2,
+	'cloud-share'
 ),
 (
-	'https://wol.shandan.one?location=outside',
-	'Wake a Device',
-	'',
-	'Wake a device from [home](https://wol.shandan.one?location=home) or [outside](https://wol.shandan.one?location=outside)',
-	'red',
-	3
+	'/code?',
+	'Track Vouchers',
+	'https://shandan.one/static/code/qr.svg',
+	'Save scanned voucher codes to keep them handy and not forgotten',
+	'azure',
+	2,
+	'barcode'
+),
+(
+	'https://mealie.shandan.one/g/das',
+	'Recipes',
+	'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true" class="v-icon__svg" style="font-size: 100px; height: 100px; width: 100px; fill: rgb(229, 131, 37)"><path d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"></path></svg>',
+	'Share recipes and meal plans',
+	'yellow',
+	3,
+	'tools-kitchen-2'
 ),
 (
 	'https://shandan.one/grocery/internal/apply.sql?groups[]=Fish,%20Meat,%20Eggs&apply=Apply&title=Trend',
@@ -71,7 +88,8 @@ VALUES
 	'https://shandan.one/grocery/favicon_square.svg',
 	'Plot charts showing historic price data',
 	'youtube',
-	3
+	3,
+	'shopping-cart'
 ),
 (
 	'https://cropswap.shandan.one/',
@@ -79,7 +97,8 @@ VALUES
 	'https://cropswap.shandan.one/user/themes/quark/images/favicon.png',
 	'Local crop sharing community',
 	'lime',
-	3
+	3,
+	'plant'
 ),
 (
 	'https://mail.cropswap.shandan.one/',
@@ -87,7 +106,17 @@ VALUES
 	'https://www.mhonarc.org/MHonArc/logo/xmhastampw_t.png.pagespeed.ic.JEPpZUA3TZ.webp',
 	'Member mailing list',
 	'github',
-	3
+	3,
+	'mail'
+),
+(
+	NULL, --'https://wol.shandan.one?location=outside',
+	'Wake a Device',
+	'data:image/svg+xml,<svg  xmlns="http://www.w3.org/2000/svg"  width="24"  height="24"  viewBox="0 0 24 24"  fill="none"  stroke="rgb(205,57,57)"  stroke-width="2"  stroke-linecap="round"  stroke-linejoin="round"  class="icon icon-tabler icons-tabler-outline icon-tabler-power"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 6a7.75 7.75 0 1 0 10 0" /><path d="M12 4l0 8" /></svg>',
+	'Wake a device from [home](https://wol.shandan.one?location=home) or [outside](https://wol.shandan.one?location=outside)',
+	'red',
+	3,
+	'power'
 ),
 (
 	'https://shandan.one/pgadmin4',
@@ -95,7 +124,8 @@ VALUES
 	'https://raw.githubusercontent.com/postgres/pgadmin4/c1ba645dceed5c9551a5f408e37a14d1041ee598/web/pgadmin/static/favicon.ico',
 	'Manage And access postgresql databases',
 	'azure',
-	3
+	3,
+	'database'
 ),
 (
 	'https://shandan.one/guacamole',
@@ -103,7 +133,8 @@ VALUES
 	'https://raw.githubusercontent.com/apache/guacamole-website/main/images/logos/guac-classic-logo.svg',
 	'Remote desktop',
 	'teal',
-	3
+	3,
+	'avocado'
 ),
 (
 	'https://gogs.shandan.one',
@@ -111,7 +142,8 @@ VALUES
 	'https://gogs.shandan.one/img/favicon.png',
 	'Manage and access git repositories',
 	'yellow',
-	3
+	3,
+	'brand-git'
 ),
 (
 	'https://syncthing.shandan.one',
@@ -119,7 +151,8 @@ VALUES
 	'https://syncthing.net/img/logo-horizontal.svg',
 	'Monitor and configure file shares',
 	'blue',
-	3
+	3,
+	'topology-complex'
 ),
 (
 	'https://shandan.one/wekan',
@@ -129,7 +162,8 @@ VALUES
 
 Manage projects and tasks',
 	'teal',
-	3
+	3,
+	'layout-kanban'
 ),
 (
 	'https://mouse.shandan.one/#remote-mouse',
@@ -139,7 +173,8 @@ Manage projects and tasks',
 
 Turn your device''s touchscreen into a remote touchpad',
 	'github',
-	3
+	3,
+	'pointer'
 ),
 (
 	'https://gateway.shandan.one',
@@ -149,7 +184,8 @@ Turn your device''s touchscreen into a remote touchpad',
 
 Manage local LAN and internet connection settings',
 	'cyan',
-	3
+	3,
+	'router'
 ),
 (
 	'https://listgarden.shandan.one',
@@ -159,7 +195,8 @@ Manage local LAN and internet connection settings',
 
 Publish news items and create new feeds',
 	'green',
-	3
+	3,
+	'seeding'
 ),
 (
 	'https://shandan.one/pyapi/random',
@@ -167,7 +204,8 @@ Publish news items and create new feeds',
 	'',
 	'Generate a template prayer following the form of the model prayer outlined in Matthew&nbsp;6:9‑13 and Luke&nbsp;11:2‑4',
 	'orange',
-	3
+	3,
+	'pray'
 )
 ;
 

+ 14 - 0
rest/bar.py

@@ -0,0 +1,14 @@
+from barcode import EAN13, Code128, Code39
+
+BARCODE_MAP = {
+  'EAN_13': EAN13,
+  'CODE_128': Code128,
+  'CODE_39': Code39,
+}
+
+def get_bar_code(meta):
+    f = BARCODE_MAP.get(meta['format'], None)
+    if f is not None:
+        return f(meta['content']).render()
+
+    return None

+ 109 - 75
rest/pyapi.py

@@ -3,25 +3,23 @@
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from io import BytesIO
+
 import time
 from bottle import (
-    Bottle,
-    default_app,
     route, request, response,
-    redirect, abort,
-    template, static_file,
+    redirect, abort, static_file,
     HTTPResponse,
 )
 from itertools import chain
-from base64 import urlsafe_b64decode as b64decode
+from base64 import b64decode, b64encode
 from linkpreview import link_preview
 
 from .hash_util import B32REGEX, normalize_base32, blake, bytes_to_base32
 from .qr import get_qr_code
-from json import dumps, load
-
-from sqlite3 import connect, Row
+from .bar import get_bar_code
+from json import dumps, load, loads
+from sqlite3 import connect
+from datetime import datetime, timezone
 
 SCHEME = "https://"
 HOST = ""
@@ -29,33 +27,90 @@ DOMAIN = "shandan.one"
 PORT = ""
 LOCATION = SCHEME + (f"{HOST}." if HOST else "") + DOMAIN + (f":{PORT}" if PORT else "")
 
-@route('/preview', method=['GET'])
-def get_preview():
-    link = request.params.link
-    if not link:
-        return dumps(None)
-    try:
-        page = link_preview(link, parser="lxml")
-        return dumps({
-            'title': page.title,
-            'img': page.absolute_image,
-            'domain': page.site_name,
-            'link': link,
-        })
-    except:
-        return dumps(None)
-
-@route('/hash', method=['POST'])
-def get_hash():
-    data = dict(map(
-        lambda x: (x[0], x[1].encode('utf-8')),
-        load(request.body).items()
+def parse_data_uri(content):
+    # extract bytes from data url
+    _, *media, data = chain.from_iterable(map(
+        lambda x: x.split(',', 1), content.split(':', 1)
     ))
-    _bytes = blake(**data)
-    return bytes_to_base32(_bytes)
+    media = media and media[0]
+    mimetype, *params, encoding = media.split(';')
+    if '=' in encoding:
+        params.append(encoding)
+        encoding = None
+    
+    return {
+        'mimetype': mimetype,
+        'params': dict(map(lambda x: x.split('='), params)),
+        'encoding': encoding,
+        'data': data,
+    }
+
+
+def parse_upload_placeholder(rowid):
+    rowid = int(rowid)
+    con = connect('util.db')
+    content = None
+    try:
+        content = con.cursor().execute("""
+SELECT content FROM upload_temp WHERE rowid = ? LIMIT 1;
+""", (rowid,)).fetchall()[0][0]
+    finally:
+        con.close()
+    
+    data = parse_data_uri(content)
+    assert data['encoding'] == 'base64', f"unsupported encoding: {data['encoding']}"
+    data = b64decode(data['data'] + '==')
+    return data
+
+
+@route('/<route:re:(clip|goto|upload|code)>/meta', method=['POST'])
+def get_meta(route):
+    response.content_type = 'application/json'
+    body = load(request.body)
+    person = route
+    if route == 'upload':
+        data = parse_upload_placeholder(body['data'])
+    elif route == 'code':
+        data = dumps(body['data'], sort_keys=True).encode('utf-8')
+    else:
+        data = body['data'].encode('utf-8')
+
+    _bytes = blake(data, person = person and person.encode('utf-8'))
+    hash = bytes_to_base32(_bytes)
+    fallback = f'https://shandan.one/{route}/{hash}'
+
+    preview = None
+    if route == 'goto':
+        link = data.decode('utf-8')
+        try:
+            page = link_preview(link, parser="lxml")
+            preview = {
+                'title': page.title,
+                'img': page.absolute_image,
+                'domain': page.site_name,
+                'link': link,
+            }
+        except:
+            pass
+    elif route == 'code':
+        if body['data']['format'] == 'QR_CODE':
+            preview = b64encode(get_qr_code(body['data']['content'], err_lvl=body['data']['errorCorrectionLevel']))
+        else:
+            preview = b64encode(get_bar_code(body['data']))
+        preview = preview.decode('utf-8')
+
+    qr = None
+    if route != 'code':
+        qr = get_qr_code(data, fallback = fallback).decode('utf-8')
 
-@route('/normalize', method=['GET'])
-def normalize():
+    return dumps({
+        'hash': hash,
+        'qr': qr,
+        'preview': preview,
+    })
+
+@route('/<route:re:(clip|goto|upload)>/normalize', method=['GET'])
+def normalize(route):
     _hash = request.params.hash
     response.content_type = 'application/json'
     return dumps({
@@ -63,26 +118,15 @@ def normalize():
         'o': normalize_base32(_hash) if _hash else None,
     })
 
-@route('/qr', method=['POST'])
-def get_qr():
-    data = load(request.body)
-    return get_qr_code(**data).decode('utf-8')
-
 @route('/static/<filename:path>')
 def send_static(filename):
     return static_file(filename, root='rest/static')
 
-@route('/<route:re:(clip|goto|upload)>/open')
-def _get_clip(route):
-    return redirect(f'/{route}/open.sql')
-
-@route('/<route:re:(clip|goto|upload)>', method=['GET', 'POST'])
-def clip(route):
-    return redirect(f'/{route}.sql')
-
-@route(f'/<route:re:(clip|goto)>/<hash:re:{B32REGEX}{{1,5}}>', method='GET')
+@route(f'/<route:re:(clip|goto|code)>/<hash:re:{B32REGEX}{{1,5}}>', method='GET')
 def get_clip(route, hash):
-    return redirect(f'/{route}.sql?hash={hash}&go=true')
+    hash = hash and normalize_base32(hash)
+    return redirect(f'/{route}/?hash={hash}&go=true')
+
 
 @route(f'/upload/<hash:re:{B32REGEX}{{1,5}}>', method='GET')
 def get_upload(hash):
@@ -90,42 +134,32 @@ def get_upload(hash):
     con = connect('util.db')
     fname, mimetype, content = (None, None, None)
     try:
-        fname, mimetype, content = con.cursor().execute(f"""
-SELECT name, mime, content
-FROM upload
-WHERE hash = '{hash}'
-LIMIT 1;
-""").fetchall()[0]
+        fname, mimetype, content, created = con.cursor().execute("""
+SELECT name, mime, content, created FROM upload WHERE hash = ? LIMIT 1;
+""", (hash,)).fetchall()[0]
     finally:
         con.close()
 
-    # extract bytes from data url
-    _, mime, encoding, content = chain.from_iterable(map(
-        lambda x: x.split(','), chain.from_iterable(map(
-            lambda x: x.split(';'), content.split(':')
-        ))
-    ))
-    assert encoding == 'base64', f'unsupported encoding: {encoding}'
-    content = b64decode(content + '==')
+    created = datetime.strptime(created, '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc).timestamp()
+    data = parse_data_uri(content)
+    assert data['mimetype'].split(';', 1)[0] == mimetype.split(';', 1)[0].split('+')[0], f"mimetype in db and data uri differ"
+    charset = data['params'].get('charset', None)
+    assert data['encoding'] == 'base64', f"unsupported encoding: {data['encoding']}"
+    content = b64decode(data['data'] + '==')
 
-    if request.params.download == "false":
-        download = False
     headers = {}
     headers['Content-Length'] = len(content)
 
     # TODO: create ext from mime type?
     headers['Content-Disposition'] = 'attachment; filename="%s"' % (fname or hash)
     headers['Content-Encoding'] = 'application/octet-stream'
-    # if mimetype == 'auto':
-    #     mimetype, encoding = mimetypes.guess_type(filename)
-    #     if encoding: headers['Content-Encoding'] = encoding
-
-    # if mimetype:
-    #     if mimetype[:5] == 'text/' and charset and 'charset' not in mimetype:
-    #         mimetype += '; charset=%s' % charset
-    #     headers['Content-Type'] = mimetype
-    #lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(created))
-    #headers['Last-Modified'] = lm
+
+    if mimetype:
+        if mimetype[:5] == 'text/' and charset and 'charset' not in mimetype:
+            mimetype += '; charset=%s' % charset
+        headers['Content-Type'] = mimetype
+    lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(created))
+    headers['Last-Modified'] = lm
     return HTTPResponse(content, **headers)
 
 @route('/<any>/', method='GET')

+ 9 - 7
rest/qr.py

@@ -2,10 +2,7 @@ from io import BytesIO
 from typing import Union
 from qrcode import QRCode
 from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M, ERROR_CORRECT_Q, ERROR_CORRECT_H
-from qrcode.image.styledpil import StyledPilImage
-from qrcode.image.svg import SvgPathImage
-from qrcode.image.styles.moduledrawers.svg import SvgCircleDrawer
-from qrcode.image.styles.colormasks import RadialGradiantColorMask
+from qrcode.image.svg import SvgPathFillImage
 
 QR_MAX_BYTES = {
     ERROR_CORRECT_H: 1273,
@@ -13,8 +10,13 @@ QR_MAX_BYTES = {
     ERROR_CORRECT_M: 2331,
     ERROR_CORRECT_L: 2953,
 }
-def get_qr_code(data: Union[bytes, str], fallback: Union[bytes, str] = None):
-    err_lvl = ERROR_CORRECT_H
+QR_QUALITY_MAP = {
+  q: locals().get(f'ERROR_CORRECT_{q}') for q in (
+    'H', 'Q', 'M', 'L'
+  )
+}
+def get_qr_code(data: Union[bytes, str], fallback: Union[bytes, str] = None, err_lvl='H'):
+    err_lvl = QR_QUALITY_MAP.get(err_lvl, err_lvl)
     qr = QRCode(error_correction=err_lvl)
     if data is not None and isinstance(data, str):
         data = data.encode('utf-8')
@@ -25,7 +27,7 @@ def get_qr_code(data: Union[bytes, str], fallback: Union[bytes, str] = None):
     else:
         qr.add_data(data or fallback, optimize=0)
 
-    img_1 = qr.make_image(image_factory=SvgPathImage)
+    img_1 = qr.make_image(image_factory=SvgPathFillImage)
     with BytesIO() as f:
         img_1.save(f)
         f.flush()

+ 1 - 0
rest/requirements.txt

@@ -4,3 +4,4 @@ base32-lib
 lxml
 linkpreview
 qrcode
+python-barcode

+ 9 - 1
rest/static/clip/clip-favicon_square.svg

@@ -44,40 +44,48 @@
    inkscape:window-maximized="1"
    inkscape:current-layer="svg68" />
 <rect
-   fill="#afafaf"
+   fill="#ffffff"
+   opacity="0"
    y="10"
    x="20"
    width="60"
    height="85"
    id="rect4" />
 <path
+   fill="#2fb344"
    d="M 84.5,7.583 H 71.125 V 5.961 c 0,-0.55 -0.45,-1 -1,-1 H 57.943 V 2 c 0,-1.1 -0.9,-2 -2,-2 H 44.057 c -1.1,0 -2,0.9 -2,2 V 4.961 H 29.875 c -0.55,0 -1,0.45 -1,1 V 7.584 H 15.5 c -0.55,0 -1,0.45 -1,1 V 99 c 0,0.55 0.45,1 1,1 h 69 c 0.55,0 1,-0.45 1,-1 V 8.583 c 0,-0.549 -0.45,-1 -1,-1 z M 50,2.319 c 1.094,0 1.981,0.887 1.981,1.98 0,1.094 -0.888,1.981 -1.981,1.981 -1.094,0 -1.981,-0.887 -1.981,-1.981 0,-1.093 0.887,-1.98 1.981,-1.98 z m 27.995,90.076 c 0,0.55 -0.45,1 -1,1 h -53.99 c -0.55,0 -1,-0.45 -1,-1 V 15.188 c 0,-0.55 0.45,-1 1,-1 h 5.87 v 1.669 c 0,0.55 0.45,1 1,1 h 40.25 c 0.55,0 1,-0.45 1,-1 v -1.669 h 5.87 c 0.55,0 1,0.45 1,1 z"
    id="path54" /><rect
+   fill="#2fb344"
    x="28.875"
    y="25.5"
    width="38.125"
    height="2.375"
    id="rect56" /><rect
+   fill="#2fb344"
    x="28.875"
    y="31.625"
    width="38.125"
    height="2.375"
    id="rect58" /><rect
+   fill="#2fb344"
    x="28.875"
    y="37.75"
    width="29.125"
    height="2.375"
    id="rect60" /><rect
+   fill="#2fb344"
    x="28.875"
    y="50"
    width="38.125"
    height="2.375"
    id="rect62" /><rect
+   fill="#2fb344"
    x="28.875"
    y="56.125"
    width="38.125"
    height="2.375"
    id="rect64" /><rect
+   fill="#2fb344"
    x="28.875"
    y="62.25"
    width="29.125"

+ 1 - 1
rest/static/clip/manifest.json

@@ -7,7 +7,7 @@
   "shortcuts": [
     {
       "name": "Open",
-      "url": "/clip/open",
+      "url": "/clip?action=open",
       "icons": [{ "src": "/static/ftark-open.png", "sizes": "96x96" }]
     },
     {

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 0
rest/static/code/qr.svg


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 2 - 2
rest/static/goto/chain-link2fr-3-2.svg


+ 1 - 1
rest/static/goto/manifest.json

@@ -7,7 +7,7 @@
   "shortcuts": [
     {
       "name": "Open",
-      "url": "/goto/open",
+      "url": "/goto?action=open",
       "icons": [{ "src": "/static/ftark-open.png", "sizes": "96x96" }]
     },
     {

BIN
rest/static/shandanone-small.png


+ 68 - 0
rest/static/shandanone-small.svg

@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="104.20946mm"
+   height="95.790558mm"
+   viewBox="0 0 104.20946 95.790558"
+   version="1.1"
+   id="svg5"
+   inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+   sodipodi:docname="shandanone-small.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview7"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="true"
+     inkscape:document-units="mm"
+     showgrid="false"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:zoom="0.56569164"
+     inkscape:cx="61.871164"
+     inkscape:cy="181.19412"
+     inkscape:window-width="1680"
+     inkscape:window-height="997"
+     inkscape:window-x="1920"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1" />
+  <defs
+     id="defs2" />
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-52.895271,-100.60472)">
+    <rect
+       style="fill:#00ffff;stroke-width:1.58288"
+       id="rect9235"
+       width="104.20946"
+       height="95.790558"
+       x="52.895271"
+       y="100.60472"
+       rx="9.1908665"
+       ry="8.2469568" />
+    <text
+       xml:space="preserve"
+       style="font-style:normal;font-weight:normal;font-size:82.0971px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.05243"
+       x="45.846691"
+       y="180.05571"
+       id="text1948"
+       transform="scale(1.040286,0.96127411)"><tspan
+         sodipodi:role="line"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:FreeMono;-inkscape-font-specification:'FreeMono Bold';stroke-width:2.05243"
+         x="45.846691"
+         y="180.05571"
+         id="tspan31781">.1</tspan></text>
+  </g>
+</svg>

BIN
rest/static/shandanone2.png


+ 78 - 0
rest/static/shandanone2.svg

@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   width="104.20946mm"
+   height="95.790558mm"
+   viewBox="0 0 104.20946 95.790558"
+   version="1.1"
+   id="svg5"
+   inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
+   sodipodi:docname="shandanone2.svg"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <sodipodi:namedview
+     id="namedview7"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="true"
+     inkscape:document-units="mm"
+     showgrid="false"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:zoom="0.56569164"
+     inkscape:cx="61.871164"
+     inkscape:cy="181.19412"
+     inkscape:window-width="1680"
+     inkscape:window-height="997"
+     inkscape:window-x="1920"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="layer1" />
+  <defs
+     id="defs2" />
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-52.895271,-100.60472)">
+    <rect
+       style="fill:#00ffff;stroke-width:1.58288"
+       id="rect9235"
+       width="104.20946"
+       height="95.790558"
+       x="52.895271"
+       y="100.60472"
+       rx="9.1908665"
+       ry="8.2469568" />
+    <text
+       xml:space="preserve"
+       style="font-style:normal;font-weight:normal;font-size:23.4617px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.586543"
+       x="66.327843"
+       y="131.03873"
+       id="text1948"
+       transform="scale(1.040286,0.96127411)"><tspan
+         sodipodi:role="line"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:FreeMono;-inkscape-font-specification:'FreeMono Bold';stroke-width:0.586543"
+         x="66.327843"
+         y="131.03873"
+         id="tspan4071">SHAN</tspan><tspan
+         sodipodi:role="line"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:FreeMono;-inkscape-font-specification:'FreeMono Bold';stroke-width:0.586543"
+         x="66.327843"
+         y="161.30981"
+         id="tspan6473"> DAN</tspan><tspan
+         sodipodi:role="line"
+         style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:FreeMono;-inkscape-font-specification:'FreeMono Bold';stroke-width:0.586543"
+         x="66.327843"
+         y="191.58092"
+         id="tspan6475"> .ONE</tspan></text>
+  </g>
+</svg>

+ 1 - 1
rest/static/upload/manifest.json

@@ -7,7 +7,7 @@
   "shortcuts": [
     {
       "name": "Open",
-      "url": "/upload/open",
+      "url": "/upload?action=open",
       "icons": [{ "src": "/static/ftark-open.png", "sizes": "96x96" }]
     },
     {

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
rest/static/upload/qr.svg


+ 1 - 1
rest/static/upload/upload-favicon_maskable.svg

@@ -26,7 +26,7 @@
    xmlns:svg="http://www.w3.org/2000/svg"><defs
    id="defs55" /><sodipodi:namedview
    id="namedview53"
-   pagecolor="#afaf0f"
+   pagecolor="#ffffff"
    bordercolor="#666666"
    borderopacity="1.0"
    inkscape:pageshadow="2"

+ 6 - 3
rest/static/upload/upload-favicon_square.svg

@@ -26,7 +26,7 @@
    xmlns:svg="http://www.w3.org/2000/svg"><defs
    id="defs55" /><sodipodi:namedview
    id="namedview53"
-   pagecolor="#afaf0f"
+   pagecolor="#ffffff"
    bordercolor="#666666"
    borderopacity="1.0"
    inkscape:pageshadow="2"
@@ -44,19 +44,22 @@
    inkscape:current-layer="svg51"
    width="100px" />
 <rect
-   fill="#afafaf"
+   fill="#ffffff"
+   opacity="0"
    y="10"
    x="20"
    width="42"
    height="23"
    id="rect603" />
 <rect
-   fill="#afafaf"
+   fill="#ffffff"
+   opacity="0"
    y="31"
    x="20"
    width="60"
    height="58"
    id="rect605" />
 <path
+   fill="#f59f00"
    d="m 52.8,34.8 c -1.5,-1.6 -4.1,-1.6 -5.6,0 0,0 -12.1,12.1 -12.5,12.5 -1.6,1.6 -1.6,4.1 0,5.7 1.5,1.6 4.1,1.6 5.7,0 0.4,-0.4 5.6,-5.6 5.6,-5.6 V 76 c 0,2.2 1.9,4.1 4,4.1 2.2,0 4,-1.9 4,-4.1 V 47.4 c 0,0 5.5,5.4 5.6,5.6 1.6,1.6 4.1,1.6 5.8,0 1.5,-1.6 1.5,-4.1 0,-5.7 C 65.3,47.1 52.8,34.8 52.8,34.8 Z m 31.5,-9.2 c 0,0 -0.4,-0.4 -19.4,-19.4 C 64.4,5.6 63.3,5 62,5 H 18.6 c -2.3,0 -4.1,1.8 -4.1,4.1 v 81.8 c 0,2.3 1.8,4.1 4.1,4.1 h 62.8 c 2.3,0 4,-1.8 4,-4.1 V 28.5 C 85.5,27.2 85,26.2 84.3,25.6 Z M 22.7,13.2 H 57.9 L 58,28.5 c 0,2.2 1.8,4 4,4 H 77.3 V 86.8 H 22.7 Z"
    id="path49" /></svg>

+ 3 - 1
util-sqlpage.json

@@ -1,4 +1,6 @@
 {
+  "max_uploaded_file_size": 5242880000,
   "max_database_pool_connections": 16,
-  "database_url": "sqlite://./util.db"
+  "database_url": "sqlite://./util.db",
+  "compress_responses": false
 }

+ 0 - 3
util-sqlpage/clip.sql

@@ -1,3 +0,0 @@
-SET inner = 'clip/Index.sql';
-SELECT 'dynamic' AS component, sqlpage.run_sql('clip/entry.sql') AS properties;
-

+ 5 - 6
util-sqlpage/clip/Index.sql

@@ -1,9 +1,8 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/Style.sql') AS properties;
-SET inner = CASE COALESCE($content,'') <> '' AND COALESCE($action, '') = 'Paste'
-  WHEN TRUE THEN 'clip/save.sql'
-  ELSE CASE COALESCE($hash,'') = ''
-    WHEN TRUE THEN 'sqlpage/Link.sql'
+SET ":inner" = CASE COALESCE(:content,'') <> '' AND COALESCE(:action, '') = 'Paste'
+  WHEN TRUE THEN 'sqlpage/save.sql'
+  ELSE CASE COALESCE(:hash, '')
+    WHEN '' THEN 'sqlpage/Link.sql'
     ELSE 'sqlpage/link.sql'
   END
 END;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 5
util-sqlpage/clip/download.sql

@@ -1,5 +0,0 @@
-SET content = (SELECT content FROM clip WHERE hash = $hash);
-SELECT 'shell-empty' AS component;
-SELECT 'text' AS component
-, $content AS html
-;

+ 0 - 9
util-sqlpage/clip/entry.sql

@@ -1,9 +0,0 @@
-SET title = 'Clip';
-SET tool = 'clip';
-SET color = '#2fb344';
-SET tabler_color = 'green';
-SET image = '/static/clip/clip-favicon_square.svg';
-SET favicon = $image;
-SET manifest = '/static/clip/manifest.json';
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;

+ 21 - 25
util-sqlpage/clip/form.sql

@@ -1,51 +1,47 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/QR.sql') AS properties;
-SET view = COALESCE($content, '') <> '' AND COALESCE($action, '') <> ('Edit as New');
+SET ":view" = COALESCE(:content, '') <> '' AND COALESCE(:action, '') <> 'Edit as New';
 SELECT 'button' AS component;
 SELECT 'Open' AS title
 , 1 AS width
-, $tabler_color AS color
-, '/clip/open.sql?' AS link
+, '/clip?action=open' AS link
 ;
 SELECT 'New' AS title
 , 1 AS width
 , 'gray-500' AS color
-, 'https://shandan.one/clip.sql?' AS link
-;
-SELECT 'Download' AS title
-, 2 AS width
-, 'gray-500' AS color
-, 'https://shandan.one/clip/download.sql?hash='||$hash AS link
+, '/clip' AS link
 ;
 
 SELECT 'form' AS component
-, '/clip.sql' AS action
-, CASE $view WHEN TRUE THEN 'Edit as New' ELSE 'Paste' END AS validate
-, $tabler_color AS validate_color
+, '/clip/' AS action
+, CASE :view WHEN TRUE THEN 'Edit as New' ELSE 'Paste' END AS validate
+, :tabler_color AS validate_color
 , 'post' AS method
 ;
 SELECT 'Paste' AS value
 , '' AS label
-, 1 AS width
 , 'hidden' type
 , 'action' AS name
-WHERE NOT $view
+WHERE NOT :view
 ;
 SELECT 'Edit as New' AS value
 , '' AS label
-, 2 AS width
-, 'hidden' type
+, 'hidden' AS type
 , 'action' AS name
-WHERE $view
+WHERE :view
+;
+SELECT :hash AS value
+, '' AS label
+, 'hidden' AS type
+, 'hash' AS name
+WHERE :view
 ;
 SELECT 'Paste something here...' AS placeholder
 , 'content' AS name
-, CASE $view WHEN FALSE THEN 'textarea' ELSE 'hidden' END AS type
+, 'textarea' AS type
 , '' AS label
---, $view AS disabled
-, CASE COALESCE($action, '')
+, 12 AS rows
+, :view::bool AS disabled
+, CASE COALESCE(:action, '')
   WHEN 'New' THEN NULL
-  ELSE $content
+  ELSE :content
 END AS value
-;
-SELECT 'code' AS component;
-SELECT $content AS contents WHERE $content IS NOT NULL AND COALESCE($action, '') NOT IN ('Edit as New', 'New');
+;

+ 28 - 0
util-sqlpage/clip/index.sql

@@ -0,0 +1,28 @@
+SET ":title" = 'Clip';
+SET ":tool" = 'clip';
+SET ":hash" = COALESCE(:hash, $hash, '');
+SET ":hash" = sqlpage.url_encode(:hash);
+SET ":color" = '#2fb344';
+SET ":tabler_color" = 'green';
+SET ":image" = '/static/clip/clip-favicon_square.svg';
+SET ":favicon" = :image;
+SET ":manifest" = '/static/clip/manifest.json';
+SET ":action" = COALESCE(:action, $action, '');
+SET ":inner" = (CASE :action
+  WHEN 'open' THEN 'sqlpage/Open.sql'
+  ELSE 'clip/Index.sql'
+END);
+SET ":spinner" = COALESCE(:content,'') <> '' AND COALESCE(:action, '') = 'Paste';
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
+SELECT 'loader-start' AS component
+, 'lg' AS size
+, :tabler_color AS color
+, '' AS spinner
+WHERE :spinner
+;
+SELECT 'progress' AS component
+, 'lg' AS size
+, :tabler_color AS color
+WHERE :spinner
+;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 2
util-sqlpage/clip/open.sql

@@ -1,2 +0,0 @@
-SET inner = 'sqlpage/Open.sql';
-SELECT 'dynamic' AS component, sqlpage.run_sql('clip/entry.sql') AS properties;

+ 4 - 22
util-sqlpage/clip/save.sql

@@ -1,29 +1,11 @@
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/hash',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'person', $tool
-    )
-);
-SET hash = sqlpage.fetch($request);
-SET fallback = 'https://shandan.one/clip/' || sqlpage.url_encode($hash);
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/qr',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'fallback', $fallback
-    )
-);
-SET qr = sqlpage.fetch($request);
-INSERT INTO clip (hash, content, qr, created) VALUES ($hash, $content, $qr, CURRENT_TIMESTAMP)
+INSERT INTO clip (hash, content, qr, created) VALUES (:hash, :content, :qr, CURRENT_TIMESTAMP)
 ON CONFLICT DO
 UPDATE SET
   content = excluded.content,
   created = excluded.created,
   qr = excluded.qr
 WHERE excluded.created > clip.created;
+SELECT 'loader-stop' AS component
+WHERE :spinner
+;
 SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/link.sql') AS properties;

+ 11 - 0
util-sqlpage/code/Index.sql

@@ -0,0 +1,11 @@
+SET ":inner" = CASE :has_post_params
+  WHEN 1 THEN CASE COALESCE(:hash, '')
+    WHEN '' THEN 'code/save.sql'
+    ELSE 'code/form-fuel.sql'
+  END
+  ELSE CASE COALESCE(:hash, '')
+    WHEN '' THEN 'code/recent.sql'
+    ELSE 'code/form-fuel.sql'
+  END
+END;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 67 - 0
util-sqlpage/code/form-fuel.sql

@@ -0,0 +1,67 @@
+SET ":created" = COALESCE(:created, (SELECT created FROM code WHERE hash = :hash));
+SET ":expiry" = COALESCE(:expiry, (SELECT expiry FROM code_detail WHERE hash = :hash));
+SET ":value" = COALESCE(:value, (SELECT value FROM code_detail WHERE hash = :hash));
+SET ":store" = COALESCE(:store, json_array((SELECT store FROM code_detail WHERE hash = :hash)));
+SET ":used" = COALESCE(:used, (SELECT used FROM code_detail WHERE hash = :hash));
+SET ":type" = COALESCE(:type, json_array((SELECT type FROM code_detail WHERE hash = :hash)));
+SET ":title" = COALESCE(:type->>0, 'New')||' Voucher';
+SET ":type" = COALESCE(:type, json_array('Fuel'));
+SET ":content" = (SELECT json(content) FROM code WHERE hash = :hash);
+SET ":validate" = 'Update';
+SET ":action" = (CASE COALESCE(:action, '') WHEN '' THEN NULL ELSE :action END);
+SET ":method" = 'post';
+SET ":preview" = (SELECT 'data:image/svg+xml;base64,'||svg FROM code WHERE hash = :hash);
+SET ":autofill" = TRUE;
+SET ":filter_config" = '[
+  {"name": "store[]", "required": true},
+  {"name": "value", "required": true},
+  {"name": "expiry", "required": true},
+    { "name": "used", "label": "Used",
+      "type": "radio", "value": "true",
+      "width": 2, "checked": "'||CASE COALESCE(:used,'')
+  WHEN 'true' THEN 'true' ELSE 'false' END||'"
+    },
+    { "name": "used", "label": "Not Used",
+      "type": "radio", "value": "false",
+      "width": 2, "checked": "'||CASE COALESCE(:used,'')
+  WHEN 'true' THEN 'false' ELSE 'true' END||'"
+    }
+]';
+
+SET ":inner" = CASE COALESCE(:action, '')
+  WHEN 'Update' THEN 'code/update.sql'
+  ELSE 'code/form.sql'
+END;
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties
+WHERE COALESCE(:action, '') <> 'Update';
+
+SELECT 'card' AS component
+, 2 AS columns
+WHERE COALESCE(:action, '') <> 'Update';
+;
+SELECT COALESCE(:store->>'0'||' ', '') || COALESCE(:expiry, :created, '') AS title
+, '
+
+Type: '||COALESCE(:type->>0,'')||'
+
+Value: $'||COALESCE(:value,'')||'
+
+Expires: '||COALESCE(:expiry,'')||'
+
+Submitted: '||COALESCE(:created,'')||'
+
+
+| Type | Content |
+|:-----|:--------|
+| ' || COALESCE(:content->>'format', '') || ' | ' || COALESCE(:content->>'content', '') || ' |
+' AS description_md
+, :preview AS top_image
+, :tabler_color AS color
+WHERE COALESCE(:action, '') <> 'Update';
+;
+
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;
+
+SELECT 'table' AS component;
+SELECT * FROM code_detail
+WHERE hash = :hash;

+ 59 - 0
util-sqlpage/code/form.sql

@@ -0,0 +1,59 @@
+SET ":filter_options" = (
+  SELECT json_group_array(json_object('name', q.k, 'options', q.o))
+  FROM (
+    SELECT options.k, jsonb_group_array(
+      jsonb_object('label', v, 'value', v, 'selected', s OR CASE :autofill WHEN TRUE THEN c = 1 ELSE FALSE END)
+      ORDER BY v) o
+    FROM (
+      SELECT DISTINCT k, v, s, count(v) OVER (
+        PARTITION BY k ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
+      ) c FROM (
+        SELECT 'store'||'[]' k, value v, TRUE s FROM (SELECT value FROM json_each($store))
+        UNION
+        SELECT 'store'||'[]', store, NULL s FROM code_detail
+        UNION
+        SELECT 'type'||'[]' k, value v, TRUE s FROM (SELECT value FROM json_each($type))
+        UNION
+        SELECT 'type'||'[]', type, NULL s FROM code_detail
+        UNION
+        SELECT 'value', value, CASE COALESCE($value, '') WHEN '' THEN FALSE ELSE value = :value END s FROM code_detail
+      )
+    ) options
+    WHERE v IS NOT NULL
+    GROUP BY options.k
+  ) q
+);
+
+SELECT 'form' AS component
+, '/code/' AS action
+, :validate AS validate
+, :tabler_color AS validate_color
+, :method AS method
+;
+SELECT COALESCE(c.v->>'name', j.v->>'name') AS name
+--, fo.j#>>'{options}' AS label
+, COALESCE(c.v->>'label', j.v->>'label') AS label
+, COALESCE(c.v->>'type', j.v->>'type') AS type
+, COALESCE(c.v->>'dropdown', j.v->>'dropdown') AS dropdown
+, COALESCE(c.v->>'create_new', j.v->>'create_new') AS create_new
+, COALESCE(c.v->>'multiple', j.v->>'multiple') AS multiple
+, COALESCE(c.v->>'placeholder', j.v->>'placeholder') AS placeholder
+, COALESCE(c.v->>'width', j.v->>'width') AS width
+, o.j->>'options' AS options
+, COALESCE(c.v->>'prefix', j.v->>'prefix') AS prefix
+, COALESCE(c.v->>'value', j.v->>'value', v.v) AS value
+, CASE COALESCE(c.v->>'type', j.v->>'type')
+    WHEN 'checkbox' THEN COALESCE(c.v->>'checked', v.v) = 'true'
+    WHEN 'radio' THEN COALESCE(c.v->>'checked', v.v) = 'true'
+    ELSE NULL
+  END AS checked
+, j.v->>'formaction'
+, COALESCE(c.v->>'required', j.v->>'required') AS required
+FROM (SELECT value v FROM json_each(sqlpage.read_file_as_text('code/json/filters.json'))) j
+FULL JOIN (SELECT value v FROM json_each(:filter_config)) c
+ON (j.v->>'name' = c.v->>'name')
+LEFT JOIN (SELECT value j FROM json_each(:filter_options)) o
+ON (o.j->>'name') = j.v->>'name'
+LEFT JOIN (SELECT "key" k, value v FROM json_each(sqlpage.variables())) v
+ON v.k = j.v->>'name' OR v.k = c.v->>'name'
+;

+ 16 - 0
util-sqlpage/code/index.sql

@@ -0,0 +1,16 @@
+SET ":has_post_params" = (SELECT 1 FROM json_each(sqlpage.variables('post')) LIMIT 1);
+SET ":content" = (SELECT json_patch(sqlpage.variables('post'), '{
+    "has_post_params": null,
+    "timestamp": null
+}'));
+SET ":title" = 'Code';
+SET ":tool" = 'code';
+SET ":hash" = COALESCE($hash, '');
+SET ":hash" = sqlpage.url_encode(:hash);
+SET ":link" = '/code';
+SET ":color" = '#f59f00';
+SET ":tabler_color" = 'azure';
+SET ":image" = '/static/code/qr.svg';
+SET ":favicon" = :image;
+--SET ":manifest" = '/static/upload/manifest.json';
+SELECT 'dynamic' AS component, sqlpage.run_sql('code/Index.sql') AS properties;

+ 24 - 0
util-sqlpage/code/json/filters.json

@@ -0,0 +1,24 @@
+[
+    { "name": "type[]", "label": "Type",
+      "type": "select", "dropdown": true, "create_new": true,
+      "width": 2
+    },
+    { "name": "store[]", "label": "Store",
+      "type": "select", "dropdown": true, "create_new": true,
+      "width": 2
+    },
+    { "name": "value", "label": "Value", "prefix": "$",
+      "type": "select", "dropdown": true, "create_new": true,
+      "width": 2
+    },
+    { "name": "expiry", "label": "Expiry",
+      "type": "date",
+      "width": 2
+    },
+    { "name": "action", "label": "", "value": "Update",
+      "type": "hidden"
+    },
+    { "name": "hash", "label": "",
+      "type": "hidden"
+    }
+]

+ 9 - 0
util-sqlpage/code/new.sql

@@ -0,0 +1,9 @@
+INSERT INTO code(hash, content, svg, created)
+VALUES (:hash, :content, :preview, CURRENT_TIMESTAMP)
+ON CONFLICT DO
+UPDATE SET
+  content = excluded.content,
+  created = excluded.created,
+  svg = excluded.svg
+WHERE excluded.created > code.created;
+SELECT 'json' AS component, json('"'||:hash||'"') AS contents;

+ 52 - 0
util-sqlpage/code/recent.sql

@@ -0,0 +1,52 @@
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
+SET ":filter_config" = '[
+  {"name": "expiry", "type": "hidden"},
+  {"name": "value", "type": "hidden"},
+  {"name": "type[]", "label": "Type",
+   "type": "select", "dropdown": true,
+   "multiple": true,
+   "width": 3
+  },
+  {"name": "store[]", "create_new": false,
+   "multiple": true,
+   "width": 3
+  },
+  {"name": "expired", "label": "Show Expired",
+   "type": "checkbox", "value": "true",
+   "width": 2
+  },
+  {"name": "used", "label": "Show Used",
+   "type": "checkbox", "value": "true",
+   "width": 2
+  }
+]';
+SET ":validate" = 'Apply';
+SET ":action" = 'Apply';
+SET ":method" = 'get';
+SET ":autofill" = FALSE;
+SELECT 'dynamic' AS component, sqlpage.run_sql('code/form.sql') AS properties;
+
+SELECT 'list' AS component;
+SELECT COALESCE(type||' ','') || COALESCE(store||' ', '') || COALESCE(expiry, created) AS title
+, COALESCE(content->>'content'||' ', '') || COALESCE(content->>'type', '') AS description
+, '/code?hash='||c.hash AS link
+FROM code c
+LEFT JOIN code_detail cd
+ON c.hash = cd.hash
+WHERE (
+    cd.expiry IS NULL OR COALESCE($expired, 'false') = 'true' OR date(cd.expiry) > date(datetime(CURRENT_TIMESTAMP, 'localtime'))
+) AND (
+    cd.used IS NULL OR COALESCE($used||'', 'false') = 'true' OR COALESCE(cd.used||'','') = 'false'
+) AND (
+    CASE COALESCE($type, '')
+      WHEN '' THEN TRUE
+      ELSE $type IS NULL OR cd.type IN (SELECT value FROM json_each($type))
+    END
+) AND (
+    CASE COALESCE($store, '')
+      WHEN '' THEN TRUE
+      ELSE $store IS NULL OR cd.store IN (SELECT value FROM json_each($store))
+    END
+)
+ORDER BY expiry, created DESC, type, c.hash NULLS FIRST
+;

+ 12 - 0
util-sqlpage/code/save.sql

@@ -0,0 +1,12 @@
+SET ":request" = json_object(
+    'method', 'POST',
+    'url', 'https://shandan.one/code/meta',
+    'headers', json_object(),
+    'body', json_object(
+        'data', json(:content)
+    )
+);
+SET ":meta" = sqlpage.fetch(:request);
+SET ":hash" = :meta->>'hash';
+SET ":preview" = :meta->>'preview';
+SELECT 'dynamic' AS component, sqlpage.run_sql('code/new.sql') AS properties;

+ 12 - 0
util-sqlpage/code/update.sql

@@ -0,0 +1,12 @@
+INSERT INTO code_detail(hash, type, store, value, expiry, used)
+VALUES (:hash, :type->>0, :store->>0, :value, :expiry, :used)
+ON CONFLICT DO
+UPDATE SET
+  type = excluded.type,
+  store = excluded.store,
+  value = excluded.value,
+  expiry = excluded.expiry,
+  used = excluded.used
+WHERE TRUE
+;
+SELECT 'redirect' AS component, '/code' AS link;

+ 0 - 11
util-sqlpage/goto.sql

@@ -1,11 +0,0 @@
-SET hash = CASE $hash = ''
-  WHEN TRUE THEN NULL
-  ELSE $hash
-END;
-SET redirect = COALESCE($go, '') = 'true' AND $hash IS NOT NULL;
-SELECT 'dynamic' AS component, sqlpage.run_sql('goto/redirect.sql') AS properties
-WHERE $redirect
-;
-SET inner = 'goto/Index.sql';
-SELECT 'dynamic' AS component, sqlpage.run_sql('goto/entry.sql') AS properties;
-

+ 5 - 6
util-sqlpage/goto/Index.sql

@@ -1,9 +1,8 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/Style.sql') AS properties;
-SET inner = CASE COALESCE($content,'') <> '' AND COALESCE($action, '') = 'Shrtn It!'
-  WHEN TRUE THEN 'goto/save.sql'
-  ELSE CASE COALESCE($hash,'') = ''
-    WHEN TRUE THEN 'sqlpage/Link.sql'
+SET ":inner" = CASE COALESCE(:content,'') <> '' AND COALESCE(:action, '') = 'Shrtn It!'
+  WHEN TRUE THEN 'sqlpage/save.sql'
+  ELSE CASE COALESCE(:hash,'')
+    WHEN '' THEN 'sqlpage/Link.sql'
     ELSE 'sqlpage/link.sql'
   END
 END;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 10
util-sqlpage/goto/entry.sql

@@ -1,10 +0,0 @@
-
-SET title = 'GoTo';
-SET tool = 'goto';
-SET color = '#dc4e41';
-SET tabler_color = 'google';
-SET image = '/static/goto/chain-link2fr-3-2.svg';
-SET favicon = $image;
-SET manifest = '/static/goto/manifest.json';
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;

+ 10 - 12
util-sqlpage/goto/form.sql

@@ -1,38 +1,36 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/QR.sql') AS properties;
-SET view = COALESCE($content, '') <> '';
+SET ":view" = COALESCE(:content, '') <> '';
 SELECT 'button' AS component;
 SELECT 'Open' AS title
 , 1 AS width
-, $tabler_color AS color
-, '/goto/open.sql?' AS link
+, '/goto?action=open' AS link
 ;
 SELECT 'New' AS title
 , 1 AS width
 , 'gray-500' AS color
-, 'https://shandan.one/goto.sql?' AS link
+, '/goto' AS link
 ;
 
 SELECT 'form' AS component
-, '/goto.sql' AS action
+, '/goto/' AS action
 , 'Shrtn It!' AS validate
-, $tabler_color AS validate_color
+, :tabler_color AS validate_color
 , 'post' AS method
-WHERE NOT $view
+WHERE NOT :view
 ;
 SELECT 'action' AS name
 , '' AS label
 , 'hidden' AS type
 , 'Shrtn It!' AS value
-WHERE NOT $view
+WHERE NOT :view
 ;
 SELECT 'content' AS name
 , '' AS label
 , 'input' AS type
-, $content AS value
+, :content AS value
 , 'Paste URL here...' AS placeholder
-WHERE NOT $view
+WHERE NOT :view
 ;
 
 SELECT 'dynamic' AS component, sqlpage.run_sql('goto/preview.sql') AS properties
-WHERE $content <> ''
+WHERE :content <> ''
 ;

+ 32 - 0
util-sqlpage/goto/index.sql

@@ -0,0 +1,32 @@
+SET ":title" = 'GoTo';
+SET ":tool" = 'goto';
+SET ":hash" = COALESCE($hash, '');
+SET ":hash" = sqlpage.url_encode(:hash);
+SET ":go" = COALESCE($go, '');
+SET ":color" = '#dc4e41';
+SET ":tabler_color" = 'google';
+SET ":image" = '/static/goto/chain-link2fr-3-2.svg';
+SET ":favicon" = :image;
+SET ":manifest" = '/static/goto/manifest.json';
+SET ":action" = COALESCE($action, :action, '');
+SET ":inner" = (CASE :action
+  WHEN 'open' THEN 'sqlpage/Open.sql'
+  ELSE 'goto/Index.sql'
+END);
+SELECT 'dynamic' AS component, sqlpage.run_sql('goto/redirect.sql') AS properties
+WHERE (:go = 'true' AND :hash <> '');
+
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
+SET ":spinner" = COALESCE(:content,'') <> '' AND COALESCE(:action, '') = 'Shrtn It!';
+SELECT 'loader-start' AS component
+, 'lg' AS size
+, :tabler_color AS color
+, '' AS spinner
+WHERE :spinner
+;
+SELECT 'progress' AS component
+, 'lg' AS size
+, :tabler_color AS color
+WHERE :spinner
+;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 2
util-sqlpage/goto/open.sql

@@ -1,2 +0,0 @@
-SET inner = 'sqlpage/Open.sql';
-SELECT 'dynamic' AS component, sqlpage.run_sql('goto/entry.sql') AS properties;

+ 9 - 12
util-sqlpage/goto/preview.sql

@@ -1,15 +1,12 @@
-SET content = COALESCE($content, '');
-SET url = 'https://shandan.one/preview?link=' || sqlpage.url_encode($content);
-SET api_results = sqlpage.fetch($url);
-SET title = $api_results->>'title';
-SET image = $api_results->>'img';
-SET domain = $api_results->>'domain';
+SET ":title" = :preview->>'title';
+SET ":image" = :preview->>'img';
+SET ":domain" = :preview->>'domain';
 SELECT 'card' AS component
-, 1 AS columns
+, 2 AS columns
 ;
-SELECT $content AS link
-, $title AS title
-, $image AS top_image
-, $domain AS description_md
-, $tabler_color AS color
+SELECT :content AS link
+, :title AS title
+, :image AS top_image
+, :domain AS description
+, :tabler_color AS color
 ;

+ 7 - 8
util-sqlpage/goto/redirect.sql

@@ -1,12 +1,11 @@
-SET content = (SELECT content FROM goto WHERE hash = $hash);
-
-SET icon = 'error-404';
-SET status = '404';
-SET title = $status||' - Not found';
-SET description = 'No such '||$tool||': '||$hash;
+SET ":content" = (SELECT content FROM goto WHERE hash = :hash);
+SET ":icon" = 'error-404';
+SET ":status" = '404';
+SET ":title" = :status||' - Not found';
+SET ":description" = 'No such '||:tool||': '||:hash;
 SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/alert.sql') AS properties
-WHERE COALESCE($content,'') = ''
+WHERE COALESCE(:content,'') = ''
 ;
 SELECT 'redirect' AS component
-, $content AS link
+, :content AS link
 ;

+ 6 - 23
util-sqlpage/goto/save.sql

@@ -1,29 +1,12 @@
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/hash',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'person', $tool
-    )
-);
-SET hash = sqlpage.fetch($request);
-SET fallback = 'https://shandan.one/goto/' || sqlpage.url_encode($hash);
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/qr',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'fallback', $fallback
-    )
-);
-SET qr = sqlpage.fetch($request);
-INSERT INTO goto (hash, content, qr, created) VALUES ($hash, $content, $qr, CURRENT_TIMESTAMP)
+INSERT INTO goto (hash, content, qr, preview, created) VALUES (:hash, :content, :qr, json(:preview), CURRENT_TIMESTAMP)
 ON CONFLICT DO
 UPDATE SET
   content = excluded.content,
   created = excluded.created,
-  qr = excluded.qr
+  qr = excluded.qr,
+  preview = excluded.preview
 WHERE excluded.created > goto.created;
+SELECT 'loader-stop' AS component
+WHERE :spinner
+;
 SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/link.sql') AS properties;

+ 9 - 6
util-sqlpage/sqlpage/Link.sql

@@ -1,16 +1,19 @@
-SET link = COALESCE($link, 'https://shandan.one/'||$tool);
+SET ":link" = COALESCE(:link, 'https://shandan.one/'||:tool);
 SELECT 'text' AS component
 , '<div class="pure-g" sty>
   <div class="pure-u-1">
-    <div class="pure-button" style="margin: 1em 0 0; background: '||$color||';">
-      <a href="'||$link||'" style="color: floralwhite;">'||$link||'</a>
+    <div class="pure-button" style="margin: 1em 0 0; background: '||:color||';">
+      <a href="'||:link||'" style="color: floralwhite;">'||:link||'</a>
     </div>
   </div>
 </div>' AS html
 ;
 
-SET inner = CASE $hash IS NULL
-  WHEN TRUE THEN $tool||'/form.sql'
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/QR.sql') AS properties
+WHERE COALESCE (:hash, '') = '';
+
+SET ":inner" = CASE COALESCE(:hash, '')
+  WHEN '' THEN :tool||'/form.sql'
   ELSE 'sqlpage/validate.sql'
 END;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 4 - 5
util-sqlpage/sqlpage/Open.sql

@@ -18,12 +18,12 @@ SELECT 'button' AS component
 ;
 SELECT 'New '||$title AS title
 , 2 AS width
-, $tabler_color AS color
-, '/'||$tool||'.sql?' AS link
+, 'gray-500' AS color
+, '/'||$tool AS link
 ;
 SELECT 'form' AS component
 , $tool||'-open' AS id
-, '/'||$tool||'.sql' AS action
+, '/'||$tool||'/' AS action
 , '' AS validate
 , 'get' AS method
 ;
@@ -41,7 +41,6 @@ SELECT 'button' AS component
 ;
 SELECT 'Submit' AS title
 , 1 AS width
-, 'gray-500' AS color
-, '/'||$tool||'.sql?' AS link
+, $tabler_color AS color
 , $tool||'-open' AS form
 ;

+ 2 - 2
util-sqlpage/sqlpage/QR.sql

@@ -1,10 +1,10 @@
-SET qr = COALESCE($qr, '<img src="/static/'||$tool||'/qr.svg"/>');
+SET ":qr" = COALESCE(:qr, '<img src="/static/'||:tool||'/qr.svg"/>');
 
 SELECT 'text' AS component
 , '<div class="pure-g" sty>
   <div class="pure-u-1">
     <details><summary>Show QR code ...</summary>
-'||$qr||'
+'||:qr||'
     </details>
   </div>
 </div>' AS html

+ 43 - 12
util-sqlpage/sqlpage/Style.sql

@@ -1,19 +1,50 @@
 SELECT 'text' AS component
 , '<style>
-body .pure-g {
-    text-align: center
+/* loader container */
+.sqlpage-loader-container {
+  position: fixed;
+  text-align: center;
+  left: 50vw;
+  top: 50vh;
+  margin-top: -5.5em;
+  margin-left: -87.5px;
+  padding-bottom: 2em;
+  height: 9em;
+  width: 175px;
 }
-details svg, details img {
-    background-color: white
+.sqlpage-loader-container:has(.status) {
+  position: inherit;
+  text-align: inherit;
+  left: inherit;
+  top: inherit;
+  margin-top: inherit;
+  margin-left: inherit;
+  padding-bottom: inherit;
+  height: inherit;
+  width: inherit;
 }
-.code-component pre {
-    border-width: 2px;
-    border-color: '||$color||';
-    border-style: solid;
+div.sqlpage-loader-start:has(+ .sqlpage-loader-stop) {
+  /* hide if followed by sqlpage-loader-stop */
+  display: none;
 }
-.card-img-top {
-    width: 40%;
-    margin: 10% 30% 10% 30%;
+/* end loader container */
+
+/* progress container */
+.sqlpage-progress-container {
+  margin: 1em 0 1em;
 }
-</style>' AS html
+div.sqlpage-progress-container:has(+ .sqlpage-progress-container) {
+  /* hide if followed by sqlpage-progress-container */
+  display: none;
+}
+.sqlpage-progress-container label {
+  text-align:left;
+  color: var(--tblr-text-primary);
+}
+.sqlpage-progress-container label:after {
+  content: "…";
+}
+/* end progress container */
+</style>
+' AS html
 ;

+ 4 - 4
util-sqlpage/sqlpage/link.sql

@@ -1,5 +1,5 @@
-SET url = 'https://shandan.one/normalize?hash=' || sqlpage.url_encode($hash);
-SET api_results = sqlpage.fetch($url);
-SET hash = $api_results->>'o';
-SET link = COALESCE('https://shandan.one/'||$tool||'/'||$hash, NULL);
+SET ":url" = 'https://shandan.one/'||:tool||'/normalize?hash='||:hash;
+SET ":api_results" = sqlpage.fetch(:url);
+SET ":hash" = :api_results->>'o';
+SET ":link" = COALESCE('https://shandan.one/'||:tool||'/'||:hash, NULL);
 SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/Link.sql') AS properties;

+ 7 - 0
util-sqlpage/sqlpage/migrations/006_code.sql

@@ -0,0 +1,7 @@
+DROP TABLE IF EXISTS code;
+CREATE TABLE IF NOT EXISTS code(
+  hash text PRIMARY KEY,
+  content text,
+  svg text,
+  created timestamp
+);

+ 9 - 0
util-sqlpage/sqlpage/migrations/007_code_detail.sql

@@ -0,0 +1,9 @@
+DROP TABLE IF EXISTS code_detail;
+CREATE TABLE IF NOT EXISTS code_detail(
+  hash text PRIMARY KEY,
+  type text,
+  store text,
+  value numeric,
+  expiry timestamp,
+  used bool
+);

+ 6 - 0
util-sqlpage/sqlpage/migrations/008_upload_temp.sql

@@ -0,0 +1,6 @@
+DROP TABLE IF EXISTS upload_temp;
+CREATE TABLE IF NOT EXISTS upload_temp(
+  name text,
+  mime text,
+  content text
+);

+ 1 - 0
util-sqlpage/sqlpage/migrations/009_goto.sql

@@ -0,0 +1 @@
+ALTER TABLE goto ADD COLUMN preview JSON;

+ 17 - 0
util-sqlpage/sqlpage/save.sql

@@ -0,0 +1,17 @@
+SET ":request" = json_object(
+    'method', 'POST',
+    'url', 'https://shandan.one/'||:tool||'/meta',
+    'headers', json_object(),
+    'timeout_ms', 15000,
+    'body', json_object(
+        'data', CASE :tool
+            WHEN 'code' THEN json(:content)
+            ELSE :content
+        END
+    )
+);
+SET ":meta" = json(sqlpage.fetch(:request));
+SET ":qr" = :meta->>'qr';
+SET ":hash" = :meta->>'hash';
+SET ":preview" = :meta->>'preview';
+SELECT 'dynamic' AS component, sqlpage.run_sql(:tool||'/save.sql') AS properties;

+ 6 - 0
util-sqlpage/sqlpage/templates/loader-start.handlebars

@@ -0,0 +1,6 @@
+<div {{#if id}}id="{{id}}"{{/if}} class="sqlpage-loader-start">
+  <div class="sqlpage-loader-container">
+    <span class="{{default spinner "spinner-border"}}
+      {{#if size}}spinner-border-{{size}}{{/if}}
+      {{#if color}}text-{{color}}{{/if}}">
+    </span>

+ 3 - 0
util-sqlpage/sqlpage/templates/loader-stop.handlebars

@@ -0,0 +1,3 @@
+  </div>
+</div>
+<div class="sqlpage-loader-stop"></div>

+ 42 - 0
util-sqlpage/sqlpage/templates/progress.handlebars

@@ -0,0 +1,42 @@
+<div class="sqlpage-progress-container">
+  <div class="progress {{~#if size}} progress-{{size}}{{/if}}">
+    <div
+      id="sqlpage-loading-{{default stage "progress"}}"
+      class="progress-bar
+      {{~#if (not percent)}} progress-bar-indeterminate{{/if}}
+      {{~#if color}} bg-{{color}}{{/if}}"
+      role="progressbar"
+      aria-valuenow="{{percent}}"
+      {{~#if percent}}style="width: {{percent}}%; display: block"{{/if}} 
+      aria-valuemin="0" aria-valuemax="100"
+      {{~#if stage}}aria-label="{{stage}}"{{/if}}>
+    </div>
+  </div>
+  {{~#if stage}}
+    <br>
+    <label for="sqlpage-loading-{{default stage "progress"}}">{{stage}}</label>
+  {{/if}}
+</div>
+{{#each_row}}
+{{#if (or percent stage)}}
+  <div class="sqlpage-progress-container">
+    <div class="progress {{~#if ../size}} progress-{{../size}}{{/if}}">
+      <div
+        id="sqlpage-loading-{{default stage "progress"}}"
+        class="progress-bar
+        {{~#if (not percent)}} progress-bar-indeterminate{{/if}}
+        {{~#if ../color}} bg-{{../color}}{{/if}}"
+        role="progressbar"
+        aria-valuenow="{{percent}}"
+        {{~#if percent}}style="width: {{percent}}%; display: block"{{/if}} 
+        aria-valuemin="0" aria-valuemax="100"
+        {{~#if stage}}aria-label="{{stage}}"{{/if}}>
+      </div>
+    </div>
+    {{~#if stage}}
+      <br>
+      <label for="sqlpage-loading-{{default stage "progress"}}">{{stage}}</label>
+    {{/if}}
+  </div>
+{{/if}}
+{{/each_row}}

+ 2 - 0
util-sqlpage/sqlpage/templates/spinner-start.handlebars

@@ -0,0 +1,2 @@
+<div class="spinner-start">
+  <span class="{{class}}"></span>

+ 2 - 0
util-sqlpage/sqlpage/templates/spinner-stop.handlebars

@@ -0,0 +1,2 @@
+</div>
+<div class="spinner-stop" />

+ 42 - 4
util-sqlpage/sqlpage/theme.sql

@@ -1,8 +1,46 @@
 SELECT 'shell' AS component
 , 'dark' AS theme
-, $title AS title
-, $image AS image
-, $favicon AS favicon
-, $manifest AS manifest
+, :title AS title
+, :link AS link
+, :image AS image
+, :favicon AS favicon
+, :manifest AS manifest
 , 'https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css' AS css
+, 'https://shandan.one/css/'||:tool||'.css' AS css
+, json('{
+    "title": "Home", "link": "/", "icon": "home"
+  }') AS menu_item
+, json(CASE :tool
+  WHEN 'clip' THEN NULL
+  ELSE '{
+            "link": "/clip",
+            "title": "Clip and Paste",
+            "icon": "clipboard"
+        }'
+  END) AS menu_item
+, json(CASE :tool
+  WHEN 'goto' THEN NULL
+  ELSE '{
+            "link": "/goto",
+            "title": "GotTo Tiny URL",
+            "icon": "link"
+        }'
+  END) AS menu_item
+, json(CASE :tool
+  WHEN 'upload' THEN NULL
+  ELSE '{
+            "link": "/upload",
+            "title": "Share File",
+            "icon": "cloud-share"
+        }'
+  END) AS menu_item
+, json(CASE :tool
+  WHEN 'code' THEN NULL
+  ELSE '{
+            "link": "/code",
+            "title": "Vouchers",
+            "icon": "barcode"
+        }'
+  END) AS menu_item
 ;
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/Style.sql') AS properties;

+ 38 - 34
util-sqlpage/sqlpage/validate.sql

@@ -1,41 +1,45 @@
-SET content = (
-SELECT content
-FROM clip
-WHERE hash = $hash AND $tool = 'clip'
-UNION
-SELECT content
-FROM goto
-WHERE hash = $hash AND $tool = 'goto'
-UNION
-SELECT content
-FROM upload
-WHERE hash = $hash AND $tool = 'upload'
+SET ":content" = (
+  SELECT content
+  FROM clip
+  WHERE hash = :hash AND :tool = 'clip'
+  UNION
+  SELECT content
+  FROM goto
+  WHERE hash = :hash AND :tool = 'goto'
+  UNION
+  SELECT content
+  FROM upload
+  WHERE hash = :hash AND :tool = 'upload'
 );
 
-SET qr = (
-SELECT qr
-FROM clip
-WHERE hash = $hash AND $tool = 'clip'
-UNION
-SELECT qr
-FROM goto
-WHERE hash = $hash AND $tool = 'goto'
-UNION
-SELECT qr
-FROM upload
-WHERE hash = $hash AND $tool = 'upload'
+SET ":qr" = (
+  SELECT qr
+  FROM clip
+  WHERE hash = :hash AND :tool = 'clip'
+  UNION
+  SELECT qr
+  FROM goto
+  WHERE hash = :hash AND :tool = 'goto'
+  UNION
+  SELECT qr
+  FROM upload
+  WHERE hash = :hash AND :tool = 'upload'
 );
 
-SET file_name = (SELECT name FROM upload WHERE hash = $hash AND $tool = 'upload');
-SET mime_type = (SELECT mime FROM upload WHERE hash = $hash AND $tool = 'upload');
+SET ":preview" = (SELECT preview FROM goto WHERE hash = :hash AND :tool = 'goto');
+SET ":file_name" = (SELECT name FROM upload WHERE hash = :hash AND :tool = 'upload');
+SET ":mime_type" = (SELECT mime FROM upload WHERE hash = :hash AND :tool = 'upload');
 
-SET inner = CASE COALESCE($content,'') = ''
-  WHEN TRUE THEN 'sqlpage/alert.sql'
-  ELSE $tool||'/form.sql'
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/QR.sql') AS properties
+WHERE COALESCE (:hash, '') <> '';
+
+SET ":inner" = CASE COALESCE(:content,'')
+  WHEN '' THEN 'sqlpage/alert.sql'
+  ELSE :tool||'/form.sql'
 END;
 
-SET icon = 'error-404';
-SET status = '404';
-SET title = $status||' - Not found';
-SET description = 'No such '||$tool||': '||$hash;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;
+SET ":icon" = 'error-404';
+SET ":status" = '404';
+SET ":title" = :status||' - Not found';
+SET ":description" = 'No such '||:tool||': '||:hash;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 6
util-sqlpage/upload.sql

@@ -1,6 +0,0 @@
-SET inner = 'upload/Index.sql';
--- although using a variable works, docs say to pass the function as first argument
--- https://sql.ophir.dev/functions.sql?function=read_file_as_data_url#function
-SET data_uri = sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('content'));
-SET mime_type = sqlpage.uploaded_file_mime_type('content');
-SELECT 'dynamic' AS component, sqlpage.run_sql('upload/entry.sql') AS properties;

+ 5 - 11
util-sqlpage/upload/Index.sql

@@ -1,14 +1,8 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/Style.sql') AS properties;
-SET content = CASE
-  WHEN COALESCE($data_uri, '') = ''
-  THEN $content
-  ELSE $data_uri
-END;
-SET inner = CASE COALESCE($content,'') <> '' AND COALESCE($action, '') = 'Upload'
-  WHEN TRUE THEN 'upload/save.sql'
-  ELSE CASE COALESCE($hash,'') = ''
-    WHEN TRUE THEN 'sqlpage/Link.sql'
+SET ":inner" = CASE COALESCE($rowid, '') <> ''
+  WHEN TRUE THEN 'sqlpage/save.sql'
+  ELSE CASE COALESCE(:hash, '')
+    WHEN '' THEN 'sqlpage/Link.sql'
     ELSE 'sqlpage/link.sql'
   END
 END;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 9
util-sqlpage/upload/entry.sql

@@ -1,9 +0,0 @@
-SET title = 'Upload';
-SET tool = 'upload';
-SET color = '#f59f00';
-SET tabler_color = 'yellow';
-SET image = '/static/upload/upload-favicon_square.svg';
-SET favicon = $image;
-SET manifest = '/static/upload/manifest.json';
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties;
-SELECT 'dynamic' AS component, sqlpage.run_sql($inner) AS properties;

+ 21 - 30
util-sqlpage/upload/form.sql

@@ -1,65 +1,56 @@
-SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/QR.sql') AS properties;
-SET view = COALESCE($content, '') <> '';
+SET ":view" = COALESCE(:content, '') <> '';
 SELECT 'button' AS component;
 SELECT 'Open' AS title
 , 1 AS width
-, $tabler_color AS color
-, '/upload/open.sql?' AS link
+, '/upload?action=open' AS link
 ;
 SELECT 'New' AS title
 , 1 AS width
 , 'gray-500' AS color
-, '/upload.sql?' AS link
+, '/upload' AS link
 ;
 SELECT 'Download' AS title
 , 2 AS width
 , 'gray-500' AS color
-, '/upload/'||$hash AS link
+, '/upload/'||:hash AS link
+WHERE :hash <> ''
 ;
 
 SELECT 'form' AS component
-, '/upload.sql' AS action
+, '/upload/' AS action
 , 'Upload' AS validate
 , 'post' AS method
-, $tabler_color AS validate_color
-WHERE NOT $view
+, :tabler_color AS validate_color
+WHERE NOT :view
 ;
 SELECT 'Upload' AS value
 , '' AS label
 , 'hidden' AS type
 , 'action' AS name
-WHERE NOT $view
-;
-SELECT 'file_name' AS name
-, '' AS label
-, TRUE AS required
-, 'name.ext' AS placeholder
-, 'File Name' AS prefix
-, 4 AS width
-WHERE NOT $view
+WHERE NOT :view
 ;
 SELECT 'content' AS name
-, CASE $view WHEN FALSE THEN 'file' ELSE 'hidden' END AS type
+, CASE :view WHEN FALSE THEN 'file' ELSE 'hidden' END AS type
 , '' AS label
-, TRUE AS required
---, $view AS disabled
-, CASE COALESCE($action, '')
+--, :view AS disabled
+, CASE COALESCE(:action, '')
   WHEN 'New' THEN NULL
-  ELSE $content
+  ELSE :content
 END AS value
 , 8 AS width
-WHERE NOT $view
+WHERE NOT :view
 ;
 SELECT 'card' as component
-, 1 as columns
-WHERE $view
+, 2 as columns
+WHERE :view
 ;
 SELECT 'Preview' as title
-, CASE WHEN substr($mime_type, 0, instr($mime_type, '/')) = 'image' THEN $content ELSE NULL END AS top_image
+, :tabler_color AS color
+, CASE WHEN substr(:mime_type, 0, instr(:mime_type, '/')) = 'image' THEN :content ELSE NULL END AS top_image
 , '
-Uploaded file type: ' || COALESCE($mime_type, 'null') ||'
+Uploaded file type: ' || COALESCE(:mime_type, 'null') ||'
 
-Uploaded file type: ' || COALESCE($file_name, 'null') ||'
+Uploaded file name: ' || COALESCE(:file_name, 'null') ||'
 ' AS description_md
-WHERE $view
+WHERE :view
 ;

+ 43 - 0
util-sqlpage/upload/index.sql

@@ -0,0 +1,43 @@
+SET ":tool" = 'upload';
+SET ":hash" = COALESCE($hash, '');
+SET ":hash" = sqlpage.url_encode(:hash);
+SET ":title" = 'Upload';
+SET ":color" = '#f59f00';
+SET ":tabler_color" = 'yellow';
+SET ":image" = '/static/upload/upload-favicon_square.svg';
+SET ":favicon" = :image;
+SET ":manifest" = '/static/upload/manifest.json';
+SET ":action" = COALESCE($action, :action, '');
+SET ":inner" = (CASE :action
+  WHEN 'open' THEN 'sqlpage/Open.sql'
+  ELSE 'upload/Index.sql'
+END);
+SET ":file_name" = sqlpage.uploaded_file_name('content');
+SET ":mime_type" = sqlpage.uploaded_file_mime_type('content');
+-- although using a variable works, docs say to pass the function as first argument
+-- https://sql.ophir.dev/functions.sql?function=read_file_as_data_url#function
+SET ":content" = sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('content'));
+
+SELECT 'dynamic' AS component, sqlpage.run_sql('upload/temp.sql') AS properties
+WHERE :content IS NOT NULL AND $rowid IS NULL;
+
+SET ":file_name" = (SELECT name FROM upload_temp WHERE rowid = $rowid);
+SET ":mime_type" = (SELECT mime FROM upload_temp WHERE rowid = $rowid);
+SET ":content" = CAST($rowid AS INTEGER);
+
+SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/theme.sql') AS properties
+--WHERE $rowid IS NULL
+;
+SET ":spinner" = :content IS NOT NULL OR $rowid IS NOT NULL; --COALESCE($rowid, '') <> '';
+SELECT 'loader-start' AS component
+, 'lg' AS size
+, :tabler_color AS color
+, '' AS spinner
+WHERE :spinner
+;
+SELECT 'progress' AS component
+, 'lg' AS size
+, :tabler_color AS color
+WHERE :spinner
+;
+SELECT 'dynamic' AS component, sqlpage.run_sql(:inner) AS properties;

+ 0 - 2
util-sqlpage/upload/open.sql

@@ -1,2 +0,0 @@
-SET inner = 'sqlpage/Open.sql';
-SELECT 'dynamic' AS component, sqlpage.run_sql('upload/entry.sql') AS properties;

+ 13 - 22
util-sqlpage/upload/save.sql

@@ -1,26 +1,8 @@
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/hash',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'person', $tool
-    )
-);
-SET hash = sqlpage.fetch($request);
-SET fallback = 'https://shandan.one/upload/' || sqlpage.url_encode($hash);
-SET request = json_object(
-    'method', 'POST',
-    'url', 'https://shandan.one/qr',
-    'headers', json_object(),
-    'body', json_object(
-        'data', $content,
-        'fallback', $fallback
-    )
-);
-SET qr = sqlpage.fetch($request);
+SET ":rowid" = :content;
+SET ":content" = (SELECT content FROM upload_temp WHERE rowid = :rowid);
+
 INSERT INTO upload (hash, content, name, mime, qr, created)
-VALUES ($hash, $content, $file_name, $mime_type, $qr, CURRENT_TIMESTAMP)
+VALUES (:hash, :content, :file_name, :mime_type, :qr, CURRENT_TIMESTAMP)
 ON CONFLICT DO
 UPDATE SET
   content = excluded.content,
@@ -29,4 +11,13 @@ UPDATE SET
   created = excluded.created,
   qr = excluded.qr
 WHERE excluded.created > upload.created;
+
+DELETE FROM upload_temp WHERE rowid = :rowid;
+
+--SELECT 'redirect' AS component, '/upload?hash='||:hash AS link
+--WHERE :rowid IS NOT NULL;
+
+SELECT 'loader-stop' AS component
+WHERE :spinner
+;
 SELECT 'dynamic' AS component, sqlpage.run_sql('sqlpage/link.sql') AS properties;

+ 3 - 0
util-sqlpage/upload/temp.sql

@@ -0,0 +1,3 @@
+DELETE FROM upload_temp WHERE name IS NULL OR mime IS NULL;
+INSERT INTO upload_temp(name, mime, content) VALUES (:file_name, :mime_type, :content)
+RETURNING 'redirect' AS component, '/upload?rowid='||rowid AS link;

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä