Browse Source

add a bunch of stuff.

mian
Gisle Aune 3 years ago
parent
commit
14ffcfd5f6
  1. 189
      package-lock.json
  2. 1
      package.json
  3. 13
      scripts/goose-mysql/20220313115117_scope.sql
  4. 17
      scripts/goose-mysql/20220313115122_scope_member.sql
  5. 7
      src/app.d.ts
  6. 2
      src/app.html
  7. 25
      src/hooks.ts
  8. 4
      src/lib/components/colors.sass
  9. 64
      src/lib/components/layout/Backgrond.svelte
  10. 5
      src/lib/components/layout/Entry.svelte
  11. 43
      src/lib/components/layout/EntryStatusLine.svelte
  12. 39
      src/lib/components/layout/Header.svelte
  13. 4
      src/lib/components/layout/Icon.svelte
  14. 67
      src/lib/components/layout/MenuCategory.svelte
  15. 77
      src/lib/components/layout/MenuItem.svelte
  16. 21
      src/lib/components/layout/StatusIcon.svelte
  17. 19
      src/lib/components/layout/StatusText.svelte
  18. 18
      src/lib/config.ts
  19. 31
      src/lib/database/interfaces.ts
  20. 48
      src/lib/database/mysql/database.ts
  21. 43
      src/lib/database/mysql/scopes.ts
  22. 12
      src/lib/models/scope.ts
  23. 16
      src/lib/models/status.ts
  24. 4
      src/lib/models/user.ts
  25. 15
      src/routes/[scope].json.ts
  26. 82
      src/routes/[scope]/__layout.svelte
  27. 6
      src/routes/[scope]/index.svelte
  28. 10
      src/routes/__layout.svelte
  29. 30
      src/routes/index.svelte
  30. 10
      src/routes/indexdata.json.ts
  31. BIN
      static/background.jpg

189
package-lock.json

@ -17,6 +17,7 @@
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-svelte3": "^3.2.1", "eslint-plugin-svelte3": "^3.2.1",
"fa-svelte": "^3.1.0", "fa-svelte": "^3.1.0",
"mysql2": "^2.3.3",
"node-sass": "^7.0.1", "node-sass": "^7.0.1",
"sass": "^1.49.9", "sass": "^1.49.9",
"svelte": "^3.44.0", "svelte": "^3.44.0",
@ -1225,6 +1226,15 @@
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"dev": true "dev": true
}, },
"node_modules/denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==",
"dev": true,
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -2176,6 +2186,15 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dev": true,
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -2467,7 +2486,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true, "dev": true,
"optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
@ -2636,6 +2654,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=",
"dev": true
},
"node_modules/is-typedarray": { "node_modules/is-typedarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@ -2803,6 +2827,12 @@
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
"dev": true "dev": true
}, },
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"dev": true
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -3102,6 +3132,53 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true "dev": true
}, },
"node_modules/mysql2": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz",
"integrity": "sha512-wxJUev6LgMSgACDkb/InIFxDprRa6T95+VEoR+xPvtngtccNH2dGjEB/fVZ8yg1gWv1510c9CvXuJHi5zUm0ZA==",
"dev": true,
"dependencies": {
"denque": "^2.0.1",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"long": "^4.0.0",
"lru-cache": "^6.0.0",
"named-placeholders": "^1.1.2",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/named-placeholders": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz",
"integrity": "sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA==",
"dev": true,
"dependencies": {
"lru-cache": "^4.1.3"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/named-placeholders/node_modules/lru-cache": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"dev": true,
"dependencies": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
}
},
"node_modules/named-placeholders/node_modules/yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
"dev": true
},
"node_modules/nan": { "node_modules/nan": {
"version": "2.15.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
@ -3540,6 +3617,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
"dev": true
},
"node_modules/psl": { "node_modules/psl": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
@ -3991,6 +4074,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4=",
"dev": true
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -4165,6 +4254,15 @@
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true "dev": true
}, },
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/sshpk": { "node_modules/sshpk": {
"version": "1.17.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
@ -5747,6 +5845,12 @@
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
"dev": true "dev": true
}, },
"denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==",
"dev": true
},
"depd": { "depd": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -6386,6 +6490,15 @@
"globule": "^1.0.0" "globule": "^1.0.0"
} }
}, },
"generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dev": true,
"requires": {
"is-property": "^1.0.2"
}
},
"get-caller-file": { "get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -6611,7 +6724,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
} }
@ -6741,6 +6853,12 @@
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
"dev": true "dev": true
}, },
"is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=",
"dev": true
},
"is-typedarray": { "is-typedarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@ -6890,6 +7008,12 @@
"integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
"dev": true "dev": true
}, },
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"dev": true
},
"lru-cache": { "lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -7117,6 +7241,49 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true "dev": true
}, },
"mysql2": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz",
"integrity": "sha512-wxJUev6LgMSgACDkb/InIFxDprRa6T95+VEoR+xPvtngtccNH2dGjEB/fVZ8yg1gWv1510c9CvXuJHi5zUm0ZA==",
"dev": true,
"requires": {
"denque": "^2.0.1",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"long": "^4.0.0",
"lru-cache": "^6.0.0",
"named-placeholders": "^1.1.2",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
}
},
"named-placeholders": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz",
"integrity": "sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA==",
"dev": true,
"requires": {
"lru-cache": "^4.1.3"
},
"dependencies": {
"lru-cache": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"dev": true,
"requires": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
}
},
"yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
"dev": true
}
}
},
"nan": { "nan": {
"version": "2.15.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
@ -7444,6 +7611,12 @@
"retry": "^0.12.0" "retry": "^0.12.0"
} }
}, },
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
"dev": true
},
"psl": { "psl": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
@ -7755,6 +7928,12 @@
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
}, },
"seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4=",
"dev": true
},
"set-blocking": { "set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -7894,6 +8073,12 @@
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true "dev": true
}, },
"sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"dev": true
},
"sshpk": { "sshpk": {
"version": "1.17.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",

1
package.json

@ -20,6 +20,7 @@
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-svelte3": "^3.2.1", "eslint-plugin-svelte3": "^3.2.1",
"fa-svelte": "^3.1.0", "fa-svelte": "^3.1.0",
"mysql2": "^2.3.3",
"node-sass": "^7.0.1", "node-sass": "^7.0.1",
"sass": "^1.49.9", "sass": "^1.49.9",
"svelte": "^3.44.0", "svelte": "^3.44.0",

13
scripts/goose-mysql/20220313115117_scope.sql

@ -0,0 +1,13 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE scope (
`id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`abbreviation` CHAR(8) NOT NULL
)
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS scope;
-- +goose StatementEnd

17
scripts/goose-mysql/20220313115122_scope_member.sql

@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE scope_member (
`scope_id` INT NOT NULL,
`user_id` CHAR(36) NOT NULL,
`name` VARCHAR(63) NOT NULL,
`owner` BOOLEAN NOT NULL,
PRIMARY KEY (`scope_id`, `user_id`),
UNIQUE (`scope_id`, `name`)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS scope_member;
-- +goose StatementEnd

7
src/app.d.ts

@ -1,11 +1,18 @@
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
import type { ScopeEntry } from "$lib/models/scope";
import type Scope from "$lib/models/scope"; import type Scope from "$lib/models/scope";
import type User from "$lib/models/user";
// See https://kit.svelte.dev/docs/types#the-app-namespace // See https://kit.svelte.dev/docs/types#the-app-namespace
// for information about these interfaces // for information about these interfaces
declare global { declare global {
declare namespace App { declare namespace App {
interface Locals {
user: User,
scopes: ScopeEntry[],
}
// interface Platform {} // interface Platform {}
// interface Session {} // interface Session {}
interface Stuff { interface Stuff {

2
src/app.html

@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style> <style>
body { body {
background-color: hsl(240, 12%, 7%);
background-color: hsl(240, 12%, 5%);
color: #aaa; color: #aaa;
padding: 0; padding: 0;
margin: 0; margin: 0;

25
src/hooks.ts

@ -0,0 +1,25 @@
import config from "$lib/config";
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async({ event, resolve }) => {
if (process.env.STUFFLOG3_USE_DUMMY_USER === "true") {
event.locals.user = {
id: "c11230be-4912-4313-83b0-410a248b5bd1",
name: "Developer",
}
} else {
event.locals.user = {id: "", name: "Guest"};
// Actual login check.
}
if (event.locals.user.id !== "") {
const db = await config.database();
event.locals.scopes = await db.withUser(event.locals.user.id).scopes().list()
} else {
event.locals.scopes = [];
}
const response = await resolve(event);
return response;
}

4
src/lib/components/colors.sass

@ -1,6 +1,9 @@
$color-entry0: hsl(240, 5%, 7%) $color-entry0: hsl(240, 5%, 7%)
$color-entry0-transparent: hsla(240, 5%, 10%, 0.7)
$color-entry1: hsl(240, 5%, 14%) $color-entry1: hsl(240, 5%, 14%)
$color-entry1-transparent: hsla(240, 5%, 17%, 0.8)
$color-entry2: hsl(240, 5%, 21%) $color-entry2: hsl(240, 5%, 21%)
$color-entry2-transparent: hsla(240, 5%, 24%, 0.8)
$color-entry3: hsl(240, 5%, 28%) $color-entry3: hsl(240, 5%, 28%)
$color-entry4: hsl(240, 5%, 35%) $color-entry4: hsl(240, 5%, 35%)
$color-entry5: hsl(240, 5%, 42%) $color-entry5: hsl(240, 5%, 42%)
@ -15,6 +18,7 @@ $color-entry13: hsl(240, 5%, 98%)
$opacity-entry4: 0.40 $opacity-entry4: 0.40
$opacity-entry5: 0.60 $opacity-entry5: 0.60
$opacity-entry6: 0.80
$color-green: hsl(120, 50%, 74%) $color-green: hsl(120, 50%, 74%)
$color-green-dark: hsl(120, 50%, 35%) $color-green-dark: hsl(120, 50%, 35%)

64
src/lib/components/layout/Backgrond.svelte

@ -0,0 +1,64 @@
<script lang="ts">
import {onMount} from "svelte";
export let initialOrientation: "portrait" | "landscape" = "portrait";
export let src = "/background.jpg";
export let opacity = 1.0;
let landscape = (initialOrientation === "landscape");
let image: HTMLImageElement;
onMount(() => {
if (image.complete && typeof(image.naturalHeight) !== 'undefined' && image.naturalHeight !== 0) {
const width = image.naturalWidth;
const height = image.naturalHeight;
landscape = (width > (height * 1.50));
} else {
image.onload = () => {
const width = image.naturalWidth;
const height = image.naturalHeight;
landscape = (width > (height * 1.50));
}
}
});
</script>
<img
bind:this={image}
class:landscape
src={src}
style={`opacity: ${opacity};`}
alt="Background"
/>
<style>
img {
/* Set rules to fill background */
width: 100%;
/* Set up proportionate scaling */
min-height: 100%;
height: auto;
/* Set up positioning */
position: fixed;
top: 0;
left: 0;
user-select: none;
-webkit-user-drag: none;
transition: 250ms;
z-index: -1;
}
img.landscape {
/* Set rules to fill background */
height: 100%;
/* Set up proportionate scaling */
min-width: 100%;
width: auto;
}
</style>

5
src/lib/components/layout/Entry.svelte

@ -9,15 +9,16 @@
display: block; display: block;
text-decoration: none; text-decoration: none;
color: $color-entry9; color: $color-entry9;
background-color: $color-entry1;
background-color: $color-entry1-transparent;
border-bottom-right-radius: 0.75em; border-bottom-right-radius: 0.75em;
margin: 0.5em 0; margin: 0.5em 0;
padding: 0.25em 0.5ch; padding: 0.25em 0.5ch;
transition: 250ms; transition: 250ms;
overflow-y: hidden; overflow-y: hidden;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
} }
div.entry:hover { div.entry:hover {
background-color: $color-entry2;
background-color: $color-entry2-transparent;
color: $color-entry11; color: $color-entry11;
} }

43
src/lib/components/layout/EntryStatusLine.svelte

@ -1,49 +1,16 @@
<script lang="ts"> <script lang="ts">
import type Status from "$lib/models/status"; import type Status from "$lib/models/status";
import type { IconName } from "./Icon.svelte";
import Icon from "./Icon.svelte";
import StatusColor from "./StatusColor.svelte"; import StatusColor from "./StatusColor.svelte";
export let status: Status
let statusText: string = "Unknown";
let statusIcon: IconName = "question";
import StatusIcon from "./StatusIcon.svelte";
import StatusText from "./StatusText.svelte";
$: switch (status) {
case 0:
statusIcon = "hourglass";
statusText = "Blocked";
break;
case 1:
statusIcon = "lightbulb";
statusText = "Available";
break;
case 2:
statusIcon = "calendar";
statusText = "Background";
break;
case 3:
statusIcon = "spinner";
statusText = "Active";
break;
case 4:
statusIcon = "check";
statusText = "Completed";
break;
case 5:
statusIcon = "times";
statusText = "Failed";
break;
case 6:
statusIcon = "times";
statusText = "Dropped";
break;
}
export let status: Status
</script> </script>
<div class="entry-status"> <div class="entry-status">
<StatusColor status={status}> <StatusColor status={status}>
<Icon name={statusIcon} />
<span>{statusText}</span>
<StatusIcon status={status} />
<StatusText status={status} />
</StatusColor> </StatusColor>
</div> </div>

39
src/lib/components/layout/Header.svelte

@ -0,0 +1,39 @@
<script lang="ts">
export let subtitle: string = "";
export let fullwidth: boolean = false;
</script>
<div class="header-wrapper" class:fullwidth>
<h1><slot></slot></h1>
<h2>{subtitle}</h2>
</div>
<style lang="scss">
div.header-wrapper {
&.fullwidth {
width: 180ch;
max-width: 95%;
margin: auto;
margin-top: 2em;
}
h1 {
margin: 0.333em 0;
margin-bottom: 0;
font-size: 3em;
font-weight: 100;
}
h2 {
font-size: 1em;
margin: 1em 0;
margin-top: 0;
font-style: italic;
font-weight: 100;
&:empty {
display: none;
}
}
}
</style>

4
src/lib/components/layout/Icon.svelte

@ -38,6 +38,8 @@
import { faThumbtack } from "@fortawesome/free-solid-svg-icons/faThumbtack"; import { faThumbtack } from "@fortawesome/free-solid-svg-icons/faThumbtack";
import { faHistory } from "@fortawesome/free-solid-svg-icons/faHistory"; import { faHistory } from "@fortawesome/free-solid-svg-icons/faHistory";
import { faLightbulb } from "@fortawesome/free-solid-svg-icons/faLightbulb"; import { faLightbulb } from "@fortawesome/free-solid-svg-icons/faLightbulb";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
const icons = { const icons = {
"clock": faClock, "clock": faClock,
@ -58,6 +60,8 @@
"calendar": faCalendar, "calendar": faCalendar,
"expand": faExpand, "expand": faExpand,
"search": faSearch, "search": faSearch,
"chevron_right": faChevronRight,
"chevron_down": faChevronDown,
}; };
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;

67
src/lib/components/layout/MenuCategory.svelte

@ -0,0 +1,67 @@
<script lang="ts">
import type Status from "$lib/models/status";
import type { IconName } from "./Icon.svelte";
import Icon from "./Icon.svelte";
import StatusColor from "./StatusColor.svelte";
import StatusIcon from "./StatusIcon.svelte";
import StatusText from "./StatusText.svelte";
export let status: Status | null = null;
export let expanded: boolean | null = null;
export let title: string = "";
let expandIcon: IconName;
function onClick() {
if (expanded !== null) {
expanded = !expanded;
}
}
$: expandIcon = expanded ? "chevron_down" : "chevron_right";
</script>
<div class="category">
<div on:click={onClick} class="category-header">
{#if expanded !== null}
<Icon name={expandIcon} />
{/if}
{#if status !== null}
<StatusColor status={status}>
<StatusIcon status={status} />
<StatusText status={status} />
</StatusColor>
{:else}
<span>{title}</span>
{/if}
</div>
{#if expanded !== false}
<div class="category-body">
<slot></slot>
</div>
{/if}
</div>
<style lang="scss">
@import "../colors.sass";
div.category {
border-left: 3px solid $color-entry1;
margin-bottom: 1em;
}
div.category-body {
margin-top: 0.1em;
}
div.category-header {
padding-left: 0.5ch;
font-weight: 600;
user-select: none;
opacity: $opacity-entry4;
:global(.icon) {
font-size: 0.75em;
}
}
</style>

77
src/lib/components/layout/MenuItem.svelte

@ -0,0 +1,77 @@
<script lang="ts">
import { page } from "$app/stores";
export let href: string = "";
export let subtitle: string = "";
let selected: boolean;
$: selected = $page.url.pathname === href;
</script>
{#if !!href}
<a href={href}>
<div on:click class="menu-item" class:selected>
<div class="subtitle">{subtitle}</div>
<div><slot></slot></div>
</div>
</a>
{:else}
<div on:click class="menu-item" class:selected>
<div class="subtitle">{subtitle}</div>
<div><slot></slot></div>
</div>
{/if}
<style lang="scss">
@import "../colors.sass";
div.menu-item {
color: $color-entry9;
font-size: 0.8em;
padding: 0.15em 0;
padding-left: 0.5ch;
transition: 0.25s;
&.selected {
color: $color-entry13;
background-color: $color-entry0-transparent;
}
> div {
display: inline;
}
@media screen and (max-width: 800px) {
> div {
display: block;
}
> div.subtitle {
font-size: 0.75em;
}
}
div.subtitle {
color: $color-entry5;
&:empty {
display: none;
}
}
&:hover {
color: $color-entry13;
background-color: $color-entry1-transparent;
div.subtitle {
color: $color-entry8;
}
}
}
a {
color: inherit;
text-decoration: none;
}
</style>

21
src/lib/components/layout/StatusIcon.svelte

@ -0,0 +1,21 @@
<script lang="ts">
import type Status from "$lib/models/status";
import Icon, { type IconName } from "./Icon.svelte";
export let status: Status;
export let block: boolean = false;
</script>
<Icon block={block} name={STATUS_ICONS[status]} />
<script lang="ts" context="module">
const STATUS_ICONS: Record<Status, IconName> = {
0: "hourglass",
1: "lightbulb",
2: "calendar",
3: "spinner",
4: "check",
5: "times",
6: "times",
}
</script>

19
src/lib/components/layout/StatusText.svelte

@ -0,0 +1,19 @@
<script lang="ts">
import type Status from "$lib/models/status";
export let status: Status;
</script>
<span>{STATUS_NAMES[status]}</span>
<script lang="ts" context="module">
const STATUS_NAMES: Record<Status, string> = {
0: "Blocked",
1: "Available",
2: "Background",
3: "Active",
4: "Completed",
5: "Failed",
6: "Dropped",
}
</script>

18
src/lib/config.ts

@ -0,0 +1,18 @@
import type { Database } from "./database/interfaces";
import MysqlDB from "./database/mysql/database";
let databasePromise: Promise<Database> | null = null;
let databaseTime: number = 0;
const config = {
database() {
if (databasePromise == null || Date.now() < (databaseTime - 60000)) {
databasePromise = MysqlDB.connectEnv();
databaseTime = Date.now();
}
return databasePromise;
}
};
export default config;

31
src/lib/database/interfaces.ts

@ -0,0 +1,31 @@
import type { ScopeEntry, ScopeInput } from "$lib/models/scope";
import type Scope from "$lib/models/scope";
import type { StatEntry } from "$lib/models/stat";
import type Stat from "$lib/models/stat";
export interface Database {
userId: string
scopes(): ScopeRepo
stats(scopeId: number): StatRepo
withUser(userId: string): Database
}
export interface ScopeRepo {
userId: string
find(id: number): Promise<Scope>
list(): Promise<ScopeEntry[]>
create(input: ScopeInput): Promise<Scope>
update(id: number, input: Partial<ScopeInput>): Promise<Scope>
delete(id: number): Promise<void>
}
export interface StatRepo {
userId: string
scopeId: number
find(id: number): Promise<Stat>
findEntries(...ids: number[]): Promise<StatEntry[]>
list(): Promise<Stat[]>
}

48
src/lib/database/mysql/database.ts

@ -0,0 +1,48 @@
import {createPool} from "mysql2/promise"
import type {Pool} from "mysql2/promise";
import type { Database, ScopeRepo, StatRepo } from "../interfaces";
import MysqlDBScopes from "./scopes";
export default class MysqlDB implements Database {
connection: Pool
userId: string;
private constructor(userId: string, connection: Connection) {
this.userId = userId;
this.connection = connection;
}
scopes(): ScopeRepo {
return new MysqlDBScopes(this.connection, this.userId);
}
stats(scopeId: number): StatRepo {
throw new Error("Method not implemented.");
}
withUser(userId: string): Database {
return new MysqlDB(userId, this.connection);
}
static async connectEnv(): Promise<MysqlDB> {
return this.connect(
process.env.STUFFLOG3_MYSQL_HOST,
parseInt(process.env.STUFFLOG3_MYSQL_PORT),
process.env.STUFFLOG3_MYSQL_USERNAME,
process.env.STUFFLOG3_MYSQL_PASSWORD,
process.env.STUFFLOG3_MYSQL_SCHEMA,
)
}
static async connect(host: string, port: number, user: string, password: string, database: string): Promise<MysqlDB> {
const connection = await createPool({
host, user, database, password, port,
waitForConnections: true,
connectionLimit: 20,
queueLimit: 0,
});
return new MysqlDB("", connection);
}
}

43
src/lib/database/mysql/scopes.ts

@ -0,0 +1,43 @@
import type {Pool} from "mysql2/promise";
import type scope from "$lib/models/scope";
import type { ScopeEntry, ScopeInput } from "$lib/models/scope";
import type { ScopeRepo } from "../interfaces";
export default class MysqlDBScopes implements ScopeRepo {
userId: string;
connection: Pool;
constructor(connection: Pool, userId: string) {
this.connection = connection;
this.userId = userId;
}
find(id: number): Promise<scope> {
throw new Error("Method not implemented.");
}
async list(): Promise<ScopeEntry[]> {
const [rows] = await this.connection.execute(`
SELECT scope.*, scope_member.name as display_name
FROM scope
INNER JOIN scope_member ON id = scope_id
WHERE user_id = ?
`, [this.userId]);
return (rows as any[]).map(r => ({
id: r.id,
name: r.name,
abbreviation: r.abbreviation,
displayName: r.display_name,
}))
}
create(input: ScopeInput): Promise<scope> {
throw new Error("Method not implemented.");
}
update(id: number, input: Partial<ScopeInput>): Promise<scope> {
throw new Error("Method not implemented.");
}
delete(id: number): Promise<void> {
throw new Error("Method not implemented.");
}
}

12
src/lib/models/scope.ts

@ -10,4 +10,16 @@ export interface ScopeEntry {
id: number id: number
name: string name: string
abbreviation: string abbreviation: string
displayName: string
}
export interface ScopeInput {
name: string
abbreviation: string
displayName: string
}
export interface ScopeMember {
name: string
owner: boolean
} }

16
src/lib/models/status.ts

@ -8,6 +8,22 @@ enum Status {
Dropped = 6, Dropped = 6,
} }
export interface LabeledStatus {
value: Status
name: string
}
export const allStatuses: LabeledStatus[] = [
{value: Status.Active, name: "Active"},
{value: Status.Background, name: "Background"},
{value: Status.Available, name: "Available"},
{value: Status.Blocked, name: "Blocked"},
{value: Status.Completed, name: "Completed"},
{value: Status.Failed, name: "Failed"},
{value: Status.Dropped, name: "Dropped"},
];
// Blocked -> Available -> Active -> Completed // Blocked -> Available -> Active -> Completed
// -> Failed // -> Failed
// -> Dropped // -> Dropped

4
src/lib/models/user.ts

@ -0,0 +1,4 @@
export default interface User {
id: string
name: string
}

15
src/routes/[scope].json.ts

@ -30,7 +30,7 @@ export const get: RequestHandler = async({params}) => {
const scopes: Scope[] = [ const scopes: Scope[] = [
{ {
id: 1, name: "3D Modeling", abbreviation: "3D",
id: 1, name: "3D Modeling", abbreviation: "3D", displayName: "Test",
stats: [ stats: [
{id: 101, name: "Asset", weight: 0.1, description: "Some description"}, {id: 101, name: "Asset", weight: 0.1, description: "Some description"},
{id: 102, name: "Tweak", weight: 0.2, description: "Some description"}, {id: 102, name: "Tweak", weight: 0.2, description: "Some description"},
@ -55,7 +55,7 @@ const scopes: Scope[] = [
] ]
}, },
{ {
id: 2, name: "Roleplay", abbreviation: "RP",
id: 2, name: "Roleplay", abbreviation: "RP", displayName: "Test",
stats: [ stats: [
{id: 201, name: "Story", weight: 3, description: "Some description"}, {id: 201, name: "Story", weight: 3, description: "Some description"},
{id: 202, name: "Story Word", weight: 0.002, description: "Some description"}, {id: 202, name: "Story Word", weight: 0.002, description: "Some description"},
@ -67,9 +67,14 @@ const scopes: Scope[] = [
{id: 208, name: "Wiki Complexity", weight: 0.5, description: "Some description"}, {id: 208, name: "Wiki Complexity", weight: 0.5, description: "Some description"},
], ],
activeProjects: [ activeProjects: [
{id: 2001, name: "Ilyna T'Rea", status: Status.Active},
{id: 2002, name: "Renala T'Iavay", status: Status.Active},
{id: 2003, name: "Background Stories", status: Status.Background},
{id: 2001, name: "Ilyna: The Morning After", status: Status.Active},
{id: 2002, name: "Renala: Finding Her Voice", status: Status.Active},
{id: 2003, name: "General Stories", status: Status.Background},
{id: 2004, name: "Uvena: Finish Wiki Page", status: Status.Active},
{id: 2005, name: "Va'ynna: Cracking at the Seams", status: Status.Active},
{id: 2006, name: "Witch Hunt: Sala's Backlog", status: Status.Active},
{id: 2007, name: "Ilyna: Complete Wiki Page", status: Status.Completed},
{id: 2008, name: "Renala: Complete Wiki Page", status: Status.Active},
] ]
}, },
//{id: 3, name: "Minecraft", abbreviation: "MC"}, //{id: 3, name: "Minecraft", abbreviation: "MC"},

82
src/routes/[scope]/__layout.svelte

@ -19,14 +19,92 @@
return; return;
} }
const STATUS_ORDER = [
Status.Active,
Status.Background,
Status.Available,
Status.Blocked,
Status.Completed,
Status.Failed,
Status.Dropped,
]
</script> </script>
<script lang="ts"> <script lang="ts">
import type Scope from "$lib/models/scope"; import type Scope from "$lib/models/scope";
import Header from "$lib/components/layout/Header.svelte";
import Status from "$lib/models/status";
import MenuCategory from "$lib/components/layout/MenuCategory.svelte";
import type { ProjectEntry } from "$lib/models/project";
import MenuItem from "$lib/components/layout/MenuItem.svelte";
export let scope: Scope; export let scope: Scope;
</script>
<h1>{scope.name}</h1>
let projects = scope.activeProjects;
let projectsByStatus: Record<Status, ProjectEntry[]>;
$: projectsByStatus = projects.reduce((p, c) => ({...p, [c.status]: [...p[c.status], c]}), {
0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [],
})
</script>
<div class="scope-layout">
<div class="column main">
<slot></slot> <slot></slot>
</div>
<div class="column side">
<Header subtitle={scope.name}>{scope.abbreviation}</Header>
<MenuCategory title="Scope">
<MenuItem href="/{scope.id}">Overview</MenuItem>
<MenuItem href="/{scope.id}/history">History</MenuItem>
</MenuCategory>
{#each STATUS_ORDER as status (status)}
{#if projectsByStatus[status].length > 0}
<MenuCategory status={status}>
{#each projectsByStatus[status] as project (project.id)}
<MenuItem href="/{scope.id}/project/{project.id}" subtitle={
project.name.includes(": ") ? project.name.split(": ")[0] + ": " : ""
}>{
project.name.includes(": ") ? project.name.split(": ").slice(1).join(": ") : project.name
}</MenuItem>
{/each}
</MenuCategory>
{/if}
{/each}
</div>
</div>
<style lang="scss">
div.scope-layout {
display: flex;
flex-direction: row-reverse;
flex-basis: 100%;
flex: 1;
width: 90ch;
max-width: 95%;
margin: auto;
@media screen and (max-width: 600px) {
width: 100%;
flex-direction: column;
}
> div.column {
display: flex;
flex-direction: column;
flex: 5;
&.side {
flex: 2;
h2 {
margin: 0;
margin-top: 1em;
font-size: 1em;
}
}
}
}
</style>

6
src/routes/[scope]/index.svelte

@ -1,5 +1,5 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import type { Load, LoadInput } from "@sveltejs/kit/types/internal";
import type { Load } from "@sveltejs/kit/types/internal";
export const load: Load = async({ stuff }) => { export const load: Load = async({ stuff }) => {
return { return {
@ -18,3 +18,7 @@
</script> </script>
<h1>{$page.stuff.scope.name}</h1> <h1>{$page.stuff.scope.name}</h1>
<style>
</style>

10
src/routes/__layout.svelte

@ -0,0 +1,10 @@
<script>
import { page } from "$app/stores";
import Backgrond from "$lib/components/layout/Backgrond.svelte";
let opacity = 0.175;
$: opacity = $page.url.pathname === "/" ? 0.3 : 0.2;
</script>
<Backgrond initialOrientation="landscape" opacity={opacity} />
<slot></slot>

30
src/routes/index.svelte

@ -17,7 +17,6 @@
error: `Failed: ${res.status}` error: `Failed: ${res.status}`
}; };
} }
</script> </script>
<script lang="ts"> <script lang="ts">
@ -29,17 +28,14 @@
import type { StandaloneRequirement } from "$lib/models/project"; import type { StandaloneRequirement } from "$lib/models/project";
import type { StandaloneSprint } from "$lib/models/sprint"; import type { StandaloneSprint } from "$lib/models/sprint";
import SprintLink from "$lib/components/frontpage/SprintLink.svelte"; import SprintLink from "$lib/components/frontpage/SprintLink.svelte";
import Header from "$lib/components/layout/Header.svelte";
export let scopes: ScopeEntry[] = []; export let scopes: ScopeEntry[] = [];
export let items: StandaloneItem[] = []; export let items: StandaloneItem[] = [];
export let requirements: StandaloneRequirement[] = []; export let requirements: StandaloneRequirement[] = [];
export let sprints: StandaloneSprint[] = []; export let sprints: StandaloneSprint[] = [];
</script> </script>
<div class="header-wrapper">
<h1>Stufflog</h1>
<h2>Logging your stuff.</h2>
</div>
<Header fullwidth subtitle="Logging your stuff">Stufflog</Header>
<main> <main>
<div class="column"> <div class="column">
@ -76,28 +72,6 @@
</main> </main>
<style lang="scss"> <style lang="scss">
div.header-wrapper {
width: 180ch;
max-width: 95%;
margin: auto;
margin-top: 2em;
h1 {
margin: 0.333em 0.333ch;
margin-bottom: 0;
font-size: 3em;
font-weight: 100;
}
h2 {
font-size: 1em;
margin: 1em 1ch;
margin-top: 0;
font-style: italic;
font-weight: 100;
}
}
p { p {
margin-top: 0.25em; margin-top: 0.25em;
} }

10
src/routes/indexdata.json.ts

@ -6,14 +6,8 @@ import type { ScopeEntry } from "$lib/models/scope"
import Status from "$lib/models/status" import Status from "$lib/models/status"
import type { StandaloneSprint } from "$lib/models/sprint" import type { StandaloneSprint } from "$lib/models/sprint"
export const get: RequestHandler = async({}) => {
const scopes: ScopeEntry[] = [
{id: 1, name: "3D Modeling", abbreviation: "3D"},
{id: 2, name: "Roleplay", abbreviation: "RP"},
{id: 3, name: "Minecraft", abbreviation: "MC"},
{id: 4, name: "Coding", abbreviation: "CODE"},
{id: 5, name: "System Administration", abbreviation: "SA"},
]
export const get: RequestHandler = async({locals}) => {
const scopes: ScopeEntry[] = locals.scopes;
const items: StandaloneItem[] = [ const items: StandaloneItem[] = [
{ {

BIN
static/background.jpg

After

Width: 1920  |  Height: 1080  |  Size: 495 KiB

Loading…
Cancel
Save