├── .node-version
├── public
├── robots.txt
├── icon-512.png
└── 404.html
├── .gitignore
├── .dockerignore
├── images
└── live-editor.png
├── Makefile
├── js
├── main.js
├── date.js
├── search.js
└── themer.js
├── live-server
├── package.json
├── vite.config.js
├── editor
│ ├── index.html
│ ├── styles.css
│ └── main.js
└── app.js
├── package.json
├── .github
├── FUNDING.yml
└── workflows
│ ├── docker-image.yml
│ └── gh-pages.yml
├── fly.toml
├── Dockerfile
├── icons.js
├── LICENSE
├── scss
├── _theme.scss
├── _modal.scss
└── styles.scss
├── vite.config.js
├── index.html
├── data.example.json
└── README.md
/.node-version:
--------------------------------------------------------------------------------
1 | v16.13.0
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | dist/
3 | data.json
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | **/node_modules
3 | **/dist
4 | live-server/data
5 |
--------------------------------------------------------------------------------
/public/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reorx/sui2/HEAD/public/icon-512.png
--------------------------------------------------------------------------------
/images/live-editor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reorx/sui2/HEAD/images/live-editor.png
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | build-image:
2 | docker build -t sui2 .
3 |
4 | run-image:
5 | docker run --rm -t -p 3300:3000 -v /tmp/sui2-data:/data sui2
6 |
--------------------------------------------------------------------------------
/js/main.js:
--------------------------------------------------------------------------------
1 | import { greet, date } from "./date";
2 | import { bindThemeButtons, loadTheme } from "./themer";
3 | import { initKeyboardSearch } from "./search"
4 |
5 | const t0 = new Date()
6 |
7 | document.addEventListener('DOMContentLoaded', async () => {
8 |
9 | loadTheme()
10 | date()
11 | greet()
12 | bindThemeButtons()
13 | initKeyboardSearch()
14 | setInterval(date, 1000 * 60)
15 | console.log('done DOMContentLoaded', `${new Date() - t0}ms`)
16 | })
17 |
--------------------------------------------------------------------------------
/live-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sui2-live-server",
3 | "private": true,
4 | "version": "1.0.0",
5 | "scripts": {
6 | "dev-backend": "nodemon --watch app.js app.js",
7 | "dev": "vite --host",
8 | "build": "rm -rf editor/dist && vite build",
9 | "clean": "rm -rf data editor/dist"
10 | },
11 | "dependencies": {
12 | "express": "^4.18.1"
13 | },
14 | "devDependencies": {
15 | "monaco-editor": "^0.34.0",
16 | "nodemon": "^2.0.20",
17 | "vite": "^3.0.7"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sui2",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --host",
8 | "build": "rm -rf dist && vite build",
9 | "preview": "vite preview"
10 | },
11 | "devDependencies": {
12 | "@iconify-json/mdi": "^1.1.33",
13 | "@iconify/utils": "^2.0.0",
14 | "fuse.js": "^6.6.2",
15 | "sass": "^1.54.8",
16 | "vite": "^3.0.7",
17 | "vite-plugin-handlebars": "^1.6.0",
18 | "vite-plugin-pwa": "^0.13.1"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/live-server/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import { resolve } from 'path'
3 |
4 |
5 | export default defineConfig({
6 | root: "editor",
7 | base: "/editor/",
8 | build: {
9 | rolupOptions: {
10 | input: {
11 | main: resolve(__dirname, 'index.html'),
12 | },
13 | },
14 | },
15 | server: {
16 | proxy: {
17 | '/api': {
18 | target: 'http://localhost:3000',
19 | },
20 | '/preview': {
21 | target: 'http://localhost:3000',
22 | },
23 | }
24 | }
25 | })
26 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [reorx]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: reorx
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/live-server/editor/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | SUI2 Live Editor
8 |
9 |
10 |
11 |
12 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SUI2
5 |
6 |
7 |
8 |
9 |
20 |
21 |
22 |
23 |
404 Not Found
24 |
25 | You are coming to the wrong place.
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - 'README.md'
9 |
10 | env:
11 | IMAGE_NAME: sui2
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v1
18 | - name: Set up QEMU
19 | uses: docker/setup-qemu-action@v2
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v2
22 | - name: Login to DockerHub
23 | uses: docker/login-action@v2
24 | with:
25 | username: ${{ secrets.DOCKER_USERNAME }}
26 | password: ${{ secrets.DOCKER_PASSWORD }}
27 | - name: Build
28 | uses: docker/build-push-action@v3
29 | with:
30 | platforms: linux/amd64, linux/arm64
31 | push: true
32 | tags: |
33 | ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}
34 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for sui2 on 2022-10-16T22:59:50+08:00
2 |
3 | app = "sui2"
4 | kill_signal = "SIGINT"
5 | kill_timeout = 5
6 | processes = []
7 |
8 |
9 | [build]
10 | image = "reorx/sui2:latest"
11 |
12 | [env]
13 |
14 | [mounts]
15 | source="sui2_data"
16 | destination="/data"
17 |
18 | [experimental]
19 | allowed_public_ports = []
20 | auto_rollback = true
21 |
22 | [[services]]
23 | http_checks = []
24 | internal_port = 3000
25 | processes = ["app"]
26 | protocol = "tcp"
27 | script_checks = []
28 | [services.concurrency]
29 | hard_limit = 25
30 | soft_limit = 20
31 | type = "connections"
32 |
33 | [[services.ports]]
34 | force_https = true
35 | handlers = ["http"]
36 | port = 80
37 |
38 | [[services.ports]]
39 | handlers = ["tls", "http"]
40 | port = 443
41 |
42 | [[services.tcp_checks]]
43 | grace_period = "1s"
44 | interval = "15s"
45 | restart_limit = 0
46 | timeout = "2s"
47 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-buster-slim
2 |
3 | # install dev dependencies for sui2/live-server
4 | WORKDIR /live-server
5 | ADD live-server/package.json ./
6 | RUN npm i --dev
7 |
8 | # build sui2/live-server frontend
9 | ADD live-server ./
10 | RUN npm run build
11 |
12 | FROM node:16-buster-slim
13 |
14 | ENV TINI_VERSION v0.19.0
15 | # requires using buildx
16 | ARG TARGETARCH
17 | ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH} /tini
18 | RUN chmod +x /tini
19 | ENTRYPOINT ["/tini", "--"]
20 |
21 | # install dependencies for sui2
22 | WORKDIR /app
23 | ADD package.json ./
24 | RUN npm i
25 |
26 | # install prod dependencies for sui2/live-server
27 | WORKDIR /app/live-server
28 | ADD live-server/package.json ./
29 | RUN npm i --omit=dev
30 |
31 | # add all files
32 | ADD . /app
33 |
34 | # copy editor dist from the last image
35 | COPY --from=0 /live-server/editor/dist ./editor/dist
36 |
37 | ENV DATA_DIR /data
38 | CMD ["node", "app.js"]
39 |
--------------------------------------------------------------------------------
/live-server/editor/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --frame-border: 5px solid #ddd;
3 | }
4 |
5 | html, body {
6 | margin: 0;
7 | padding: 0;
8 | height: 100%;
9 | position: relative;
10 | overflow: hidden;
11 | }
12 |
13 | .main {
14 | position: absolute;
15 | left: 0; right: 0;
16 | top: 0; bottom: 0;
17 | display: flex;
18 | }
19 | .main .left,
20 | .main .right {
21 | flex: 1;
22 | height: 100%;
23 | }
24 | .main .left {
25 | display: flex;
26 | flex-direction: column;
27 | }
28 | .main .right {
29 | border-left: var(--frame-border);
30 | }
31 |
32 | /* left */
33 |
34 | .header {
35 | display: flex;
36 | justify-content: space-between;
37 | padding: 0 15px;
38 | }
39 | .header button {
40 | height: 40px;
41 | align-self: center;
42 | }
43 | .editor {
44 | flex-grow: 1;
45 | position: relative;
46 | border-top: var(--frame-border);
47 | }
48 |
49 | /* right */
50 |
51 | .main .right > iframe {
52 | width: 100%;
53 | height: 100%;
54 | }
55 |
--------------------------------------------------------------------------------
/icons.js:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { readFileSync } from 'fs'
3 | import { getIconData, iconToSVG, replaceIDs } from '@iconify/utils';
4 |
5 | const iconsPath = resolve(__dirname, 'node_modules/@iconify-json/mdi/icons.json')
6 | const iconsData = JSON.parse(readFileSync(iconsPath))
7 | // console.log(Object.keys(iconsData))
8 |
9 | const svgAttributesBase = {
10 | 'xmlns': 'http://www.w3.org/2000/svg',
11 | 'xmlns:xlink': 'http://www.w3.org/1999/xlink',
12 | }
13 |
14 | export const getIconSVG = function(name) {
15 | const icon = getIconData(iconsData, name)
16 | if (!icon) return
17 | const renderData = iconToSVG(icon, {
18 | height: 'auto',
19 | });
20 |
21 | const svgAttributes = {
22 | ...svgAttributesBase,
23 | ...renderData.attributes,
24 | };
25 |
26 | const svgAttributesStr = Object.keys(svgAttributes)
27 | .map(
28 | (attr) => `${attr}="${svgAttributes[attr]}"`
29 | )
30 | .join(' ');
31 |
32 | // Generate SVG
33 | const svg = ``;
34 | return svg
35 | }
36 |
--------------------------------------------------------------------------------
/js/date.js:
--------------------------------------------------------------------------------
1 | export function date() {
2 | let currentDate = new Date();
3 | let dateOptions = {
4 | weekday: "long",
5 | year: "numeric",
6 | month: "long",
7 | day: "numeric",
8 | };
9 | let date = currentDate.toLocaleDateString("en-GB", dateOptions);
10 | const time = currentDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false});
11 | document.getElementById("header_date").innerHTML = `${date}${time}`;
12 | }
13 |
14 | export function greet() {
15 | let currentTime = new Date();
16 | let greet = Math.floor(currentTime.getHours() / 6);
17 | switch (greet) {
18 | case 0:
19 | document.getElementById("header_greet").innerHTML = "Good night :)";
20 | break;
21 | case 1:
22 | document.getElementById("header_greet").innerHTML = "Good morning :)";
23 | break;
24 | case 2:
25 | document.getElementById("header_greet").innerHTML = "Good afternoon :)";
26 | break;
27 | case 3:
28 | document.getElementById("header_greet").innerHTML = "Good evening :)";
29 | break;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - 'README.md'
9 |
10 | jobs:
11 | deploy:
12 | runs-on: ubuntu-20.04
13 | concurrency:
14 | group: ${{ github.workflow }}-${{ github.ref }}
15 | steps:
16 | - uses: actions/checkout@v3
17 |
18 | - name: Use Node.js 16.x
19 | uses: actions/setup-node@v1
20 | with:
21 | node-version: 16.x
22 |
23 | - name: Cache node modules
24 | id: cache-npm
25 | uses: actions/cache@v3
26 | env:
27 | cache-name: cache-node-modules
28 | with:
29 | # npm cache files are stored in `~/.npm` on Linux/macOS
30 | path: ~/.npm
31 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
32 | restore-keys: |
33 | ${{ runner.os }}-build-${{ env.cache-name }}-
34 | ${{ runner.os }}-build-
35 | ${{ runner.os }}-
36 | - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
37 | name: List the state of node modules
38 | continue-on-error: true
39 | run: npm list
40 |
41 | - run: npm i
42 | - run: npm run build
43 | env:
44 | WEBMANIFEST_SCOPE: /sui2/
45 |
46 | - name: Deploy
47 | uses: peaceiris/actions-gh-pages@v3
48 | if: ${{ github.ref == 'refs/heads/master' }}
49 | with:
50 | github_token: ${{ secrets.GITHUB_TOKEN }}
51 | publish_dir: ./dist
52 |
--------------------------------------------------------------------------------
/scss/_theme.scss:
--------------------------------------------------------------------------------
1 | .theme-button {
2 | font-size: 0.8em;
3 | margin: 2px;
4 | width: 128px;
5 | line-height: 3em;
6 | text-align: center;
7 | text-transform: uppercase;
8 | }
9 |
10 | .theme-blackboard {
11 | background-color: #000000;
12 | border: 4px solid #5c5c5c;
13 | color: #fffdea;
14 | }
15 |
16 | .theme-gazette {
17 | background-color: #f2f7ff;
18 | border: 4px solid #5c5c5c;
19 | color: #000000;
20 | }
21 |
22 | .theme-espresso {
23 | background-color: #21211f;
24 | border: 4px solid #4e4e4e;
25 | color: #d1b59a;
26 | }
27 |
28 | .theme-cab {
29 | background-color: #feed01;
30 | border: 4px solid #424242;
31 | color: #1f1f1f;
32 | }
33 |
34 | .theme-cloud {
35 | background-color: #f1f2f0;
36 | border: 4px solid #35342f;
37 | color: #37bbe4;
38 | }
39 |
40 | .theme-lime {
41 | background-color: #263238;
42 | border: 4px solid #aabbc3;
43 | color: #aeea00;
44 | }
45 |
46 | .theme-passion {
47 | background-color: #f5f5f5;
48 | border: 4px solid #8e24aa;
49 | color: #12005e;
50 | }
51 |
52 | .theme-blues {
53 | background-color: #2b2c56;
54 | border: 4px solid #6677eb;
55 | color: #eff1fc;
56 | }
57 |
58 | .theme-chalk {
59 | background-color: #263238;
60 | border: 4px solid #ff869a;
61 | color: #aabbc3;
62 | }
63 |
64 | .theme-tron {
65 | background-color: #242b33;
66 | border: 4px solid #6ee2ff;
67 | color: #effbff;
68 | }
69 |
70 | .theme-paper {
71 | background-color: #f8f6f1;
72 | border: 4px solid #f5e1a4;
73 | color: #4c432e;
74 | }
75 |
76 | .theme-initial {
77 | background-color: initial;
78 | border: 4px solid #000000;
79 | color: initial;
80 | }
81 |
--------------------------------------------------------------------------------
/scss/_modal.scss:
--------------------------------------------------------------------------------
1 | #modal {
2 | overflow-y: auto;
3 | bottom: 0;
4 | left: 0;
5 | display: none;
6 | pointer-events: none;
7 | position: fixed;
8 | right: 0;
9 | top: 0;
10 | transition: all 0.3s;
11 | z-index: 20;
12 |
13 | &:target {
14 | display: block;
15 | pointer-events: auto;
16 | }
17 |
18 | > div {
19 | background-color: #ffffff;
20 | box-shadow: 0 14px 28px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.25);
21 | margin-left: auto;
22 | margin-right: auto;
23 | padding: 2em;
24 | margin-top: calc(50vh - 220px);
25 | width: 50%;
26 | display: flex;
27 | flex-direction: column;
28 | }
29 |
30 | h1 {
31 | color: #333333;
32 | font-size: 2em;
33 | margin: 0 0 .5em;
34 | }
35 |
36 | h2 {
37 | margin: 1em 0 .5em;
38 | color: initial;
39 | }
40 |
41 | header {
42 | display: flex;
43 | justify-content: space-between;
44 | }
45 |
46 | footer {
47 | padding-top: 1em;
48 | border-top: 1px solid #aaa;
49 | a {
50 | margin-right: 1.5em;
51 | color: initial;
52 | }
53 | }
54 | }
55 |
56 | .modal-close {
57 | color: #000000;
58 | font-size: 1.5em;
59 | text-align: center;
60 | text-decoration: none;
61 | }
62 |
63 | .modal-close:hover {
64 | color: #000;
65 | }
66 |
67 | #modal_init a {
68 | bottom: 1vh;
69 | color: var(--color-text-acc);
70 | left: 1vw;
71 | position: fixed;
72 | }
73 |
74 | #modal_init a:hover {
75 | color: var(--color-text-pri);
76 | }
77 |
78 | #modal-theme {
79 | border-bottom: 0px solid var(--color-text-acc);
80 | display: flex;
81 | flex-wrap: wrap;
82 | margin-bottom: 2em;
83 | }
84 |
--------------------------------------------------------------------------------
/live-server/editor/main.js:
--------------------------------------------------------------------------------
1 | import * as monaco from 'monaco-editor'
2 | // import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
3 | import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
4 | import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
5 |
6 | self.MonacoEnvironment = {
7 | getWorker: function (workerId, label) {
8 | switch (label) {
9 | case 'json':
10 | return new jsonWorker()
11 | default:
12 | return new editorWorker()
13 | }
14 | },
15 | };
16 |
17 |
18 | const editor = monaco.editor.create(
19 | document.querySelector('.editor'),
20 | {
21 | language: 'json',
22 | lineNumbers: 'off',
23 | scrollBeyondLastLine: false,
24 | readOnly: false,
25 | theme: 'vs-light',
26 | minimap: {
27 | enabled: false,
28 | },
29 | wordWrap: 'on',
30 | })
31 |
32 | fetch('/api/getData')
33 | .then(res => res.text())
34 | .then(body => {
35 | editor.setValue(body)
36 | })
37 |
38 | const runBuild = async () => {
39 | const res = await fetch('/api/updateDataFile', {
40 | method: 'POST',
41 | body: editor.getValue(),
42 | })
43 | const data = await res.json()
44 | if (!data.ok) {
45 | throw 'failed to update data file'
46 | }
47 | console.log('update data file success')
48 |
49 | const res1 = await fetch('/api/build', {
50 | method: 'POST',
51 | })
52 | const text = await res1.text()
53 | console.log(text)
54 | }
55 |
56 | const buildBtn = document.querySelector('.fn-build')
57 | buildBtn.addEventListener('click', async (e) => {
58 | e.preventDefault()
59 | e.target.disabled = true
60 |
61 | const enableTarget = () => {
62 | e.target.disabled = false
63 | }
64 |
65 | try {
66 | await runBuild()
67 | } catch(e) {
68 | alert(e)
69 | enableTarget()
70 | }
71 |
72 | enableTarget()
73 | document.querySelector('#preview').contentWindow.location.reload(true);
74 | })
75 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import { resolve } from 'path'
3 | import { readFileSync } from 'fs'
4 | import handlebars from 'vite-plugin-handlebars'
5 | import { VitePWA } from 'vite-plugin-pwa'
6 | import { getIconSVG } from './icons'
7 |
8 | // envs
9 | const DATA_FILE = process.env.DATA_FILE,
10 | OUT_DIR = process.env.OUT_DIR,
11 | WEBMANIFEST_NAME = process.env.WEBMANIFEST_NAME,
12 | WEBMANIFEST_DESCRIPTION = process.env.WEBMANIFEST_DESCRIPTION,
13 | WEBMANIFEST_SHORT_NAME = process.env.WEBMANIFEST_SHORT_NAME,
14 | WEBMANIFEST_SCOPE = process.env.WEBMANIFEST_SCOPE,
15 | NO_PWA = process.env.NO_PWA;
16 |
17 | let dataFile = DATA_FILE || './data.json'
18 | console.log('use DATA_FILE: ', dataFile)
19 |
20 | var data
21 | try {
22 | data = JSON.parse(readFileSync(dataFile))
23 | } catch (e) {
24 | if (e.code === 'ENOENT' && !DATA_FILE) {
25 | console.log('data.json missing, fall back to data.example.json')
26 | data = await import('./data.example.json')
27 | } else {
28 | throw e;
29 | }
30 | }
31 |
32 | const manifest = {
33 | "name": WEBMANIFEST_NAME || "SUI2",
34 | "short_name": WEBMANIFEST_SHORT_NAME || "sui2",
35 | "description": WEBMANIFEST_DESCRIPTION || "a startpage for your server and / or new tab page",
36 | "icons": [
37 | {
38 | "src": "icon-512.png",
39 | "type": "image/png",
40 | "sizes": "512x512"
41 | }
42 | ],
43 | "scope": "/",
44 | "start_url": "/",
45 | "display": "standalone"
46 | }
47 |
48 | if (WEBMANIFEST_SCOPE) {
49 | manifest.scope = WEBMANIFEST_SCOPE
50 | manifest.start_url = WEBMANIFEST_SCOPE
51 | }
52 |
53 | export default defineConfig({
54 | // use relative path for assets
55 | base: "",
56 | build: {
57 | // put assets in the same folder as index.html
58 | assetsDir: ".",
59 | outDir: OUT_DIR || 'dist',
60 | rollupOptions: {
61 | input: {
62 | main: resolve(__dirname, 'index.html'),
63 | },
64 | },
65 | },
66 | plugins: [
67 | NO_PWA ? null
68 | : VitePWA({
69 | injectRegister: 'auto',
70 | registerType: 'autoUpdate',
71 | // https://developer.chrome.com/docs/workbox/modules/workbox-build/#generatesw-mode
72 | workbox: {
73 | globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
74 | // https://developer.chrome.com/docs/workbox/reference/workbox-build/#property-GeneratePartial-navigateFallback
75 | navigateFallback: '404.html',
76 | },
77 | manifest,
78 | }),
79 | handlebars({
80 | context: data,
81 | helpers: {
82 | iconify: (name) => {
83 | const svg = getIconSVG(name)
84 | if (!svg) return `no icon ${name}`
85 | return svg
86 | },
87 | domain: (url) => {
88 | var o = new URL(url);
89 | if (o.port) {
90 | return `${o.hostname}:${o.port}`
91 | }
92 | return o.hostname
93 | }
94 | }
95 | }),
96 | ].filter(x => x !== null),
97 | })
98 |
--------------------------------------------------------------------------------
/live-server/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const { exec } = require('child_process');
3 | const path = require('path');
4 | const fs = require('fs')
5 | const bodyParser = require('body-parser');
6 | const { resolve } = require('path');
7 |
8 | const app = express()
9 | const port = Number(process.env.PORT || '3000')
10 |
11 | // const isDev = process.env.NODE_ENV === 'dev'
12 |
13 | // place to build sui2
14 | buildDir = path.resolve(__dirname, '..')
15 | console.log('buildDir', buildDir)
16 |
17 | // place to get editor resources
18 | editorDir = path.resolve(__dirname, 'editor/dist')
19 |
20 | // place to store data generated by live-server
21 | dataDir = process.env.DATA_DIR || 'data'
22 | if (!path.isAbsolute(dataDir)) {
23 | path.resolve(dataDir)
24 | }
25 | dataFilePath = path.resolve(dataDir, 'data.json')
26 | console.log('dataFilePath', dataFilePath)
27 | outDir = path.resolve(dataDir, 'dist')
28 |
29 | // functions
30 |
31 | const buildStartpage = async (callback) => {
32 | const cmd = 'npm run build'
33 | const newEnv = {
34 | ...process.env,
35 | DATA_FILE: dataFilePath,
36 | OUT_DIR: outDir,
37 | NO_PWA: 1,
38 | }
39 | console.log(`* exec: ${cmd}`);
40 | return exec(cmd, {
41 | 'cwd': buildDir,
42 | 'env': newEnv,
43 | }, callback)
44 | }
45 |
46 | // start up check
47 |
48 | if (!fs.existsSync(dataDir)) {
49 | fs.mkdirSync(dataDir)
50 | }
51 |
52 | if (!fs.existsSync(dataFilePath)) {
53 | console.log('copy example file to DATA_DIR')
54 | fs.copyFileSync(path.resolve(buildDir, 'data.example.json'), dataFilePath)
55 | }
56 |
57 | if (!fs.existsSync(path.resolve(outDir, 'index.html'))) {
58 | console.log('run initial build')
59 | buildStartpage((err, stdout, stderr) => {
60 | if (err) {
61 | console.error('build failed:', err)
62 | return
63 | }
64 | console.log(`build result:
65 | stdout=${stdout}
66 | stderr=${stderr}`);
67 | })
68 | }
69 |
70 | // server code
71 |
72 | app.use(bodyParser.text({type: 'text/plain'}))
73 |
74 | app.get('/api/getData', (req, res) => {
75 | const data = fs.readFileSync(dataFilePath)
76 | res.setHeader('Content-Type', 'application/json')
77 | res.send(data)
78 | })
79 |
80 | app.post('/api/updateDataFile', (req, res) => {
81 | rawBody = req.body
82 | try {
83 | JSON.parse(rawBody)
84 | } catch(e) {
85 | console.log('rawBody')
86 | console.log(rawBody)
87 | res.status(400).send(`JSON parse error: ${e}`)
88 | return
89 | }
90 |
91 | // save to data dir
92 | fs.writeFileSync(dataFilePath, rawBody)
93 | res.send(JSON.stringify({ok: 1}))
94 | })
95 |
96 |
97 | app.post('/api/build', async (req, res) => {
98 | buildStartpage((err, stdout, stderr) => {
99 | if (err) {
100 | const errMsg = `Error: ${err}`
101 | console.warn(errMsg);
102 | res.status(500).send(errMsg);
103 | return
104 | }
105 |
106 | console.log(stdout);
107 | res.send(`Success
108 | stdout: ${stdout}
109 | stderr: ${stderr}`);
110 | })
111 | })
112 |
113 | app.use('/', express.static(outDir))
114 |
115 | // /preview also serves the outDir, so that it could be proxies from vite dev server
116 | app.use('/preview', express.static(outDir))
117 |
118 | app.use('/editor', express.static(editorDir))
119 |
120 | app.listen(port, () => {
121 | console.log(`live-server app listening on port ${port}`)
122 | })
123 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SUI2
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
23 |
24 |
Color themes
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
56 |
57 | {{#apps}}
58 |
59 | {{name}}
60 |
61 | {{#items}}
62 |
63 |
74 | {{/items}}
75 |
76 |
77 | {{/apps}}
78 |
79 |
80 | Bookmarks
81 |
82 | {{#bookmarks}}
83 |
84 |
{{category}}
85 | {{#links}}
86 |
{{name}}
87 | {{/links}}
88 |
89 | {{/bookmarks}}
90 |
91 |
92 |
93 |
94 |
99 |
100 |
101 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/js/search.js:
--------------------------------------------------------------------------------
1 | import Fuse from 'fuse.js'
2 |
3 | const store = {
4 | keyword: '',
5 | searchItems: null,
6 | fuse: null,
7 | }
8 |
9 | function loadSearchItems() {
10 | const items = []
11 | // loop .apps_item
12 | document.querySelectorAll('.apps_item').forEach(el => {
13 | const nameEl = el.querySelector('.name')
14 | items.push({
15 | name: nameEl.textContent,
16 | el,
17 | nameEl,
18 | clsss: 'apps_item',
19 | })
20 | })
21 |
22 | // loop .links_item
23 | document.querySelectorAll('.links_item').forEach(el => {
24 | const nameEl = el
25 | items.push({
26 | name: nameEl.textContent,
27 | el,
28 | nameEl,
29 | clsss: 'links_item',
30 | })
31 | })
32 |
33 | store.searchItems = items
34 | store.fuse = new Fuse(items, {
35 | keys: ['name'],
36 | includeScore: true,
37 | includeMatches: true,
38 | minMatchCharLength: 1,
39 | threshold: 0.2,
40 | })
41 | }
42 |
43 | const keywordEl = document.getElementById("keyword")
44 | const regularCharsRe = /\w/
45 |
46 | function updateKeyword(key) {
47 | // backspace
48 | if (key == 8) {
49 | if (store.keyword.length > 0) {
50 | store.keyword = store.keyword.slice(0, store.keyword.length - 1)
51 | }
52 | } else if (key == 27) { // ESC
53 | store.keyword = ''
54 | } else {
55 | // convert key code to string, see https://stackoverflow.com/a/5829387/596206
56 | let char = String.fromCharCode((96 <= key && key <= 105) ? key-48 : key)
57 | if (!regularCharsRe.test(char)) {
58 | char = ''
59 | }
60 | // console.log('key', key, `|${char}|`)
61 |
62 | if (char) {
63 | store.keyword = store.keyword + char
64 | }
65 | }
66 | if (store.keyword) {
67 | keywordEl.innerHTML = `${store.keyword}`
68 | } else {
69 | keywordEl.innerHTML = ''
70 | }
71 | return store.keyword
72 | }
73 |
74 | function handleKeyPress(e) {
75 | var key = e.keyCode || e.which;
76 | if (e.ctrlKey || e.metaKey) {
77 | // ignore key combination
78 | return
79 | }
80 | if (key == 9 || key == 13) { // Tab to switch and Enter to open
81 | // e.preventDefault();
82 | // e.stopPropagation();
83 | // use default behavior
84 | return
85 | } else {
86 | const oldKeyword = store.keyword
87 | const keyword = updateKeyword(key)
88 | // ignore empty
89 | if (oldKeyword === keyword && keyword === '') return
90 |
91 | // only search when keyword changes
92 | if (keyword !== oldKeyword) {
93 | const items = store.fuse.search(keyword)
94 | console.log('searched', keyword, items)
95 | handleMatchedItems(items)
96 | }
97 | }
98 | }
99 |
100 | function handleMatchedItems(items) {
101 | document.activeElement.blur();
102 | // reset tabindex and name text
103 | const matchedClass = 'matched'
104 | store.searchItems.forEach(item => {
105 | item.el.setAttribute('tabindex', 0)
106 | item.nameEl.innerHTML = item.name
107 | item.el.classList.remove(matchedClass)
108 | })
109 |
110 | items.forEach((i, index) => {
111 | const item = i.item
112 | if (index === 0) {
113 | item.el.focus();
114 | }
115 | const tabindex = index + 1
116 | item.el.setAttribute('tabindex', tabindex)
117 | item.el.classList.add(matchedClass)
118 |
119 | // because we only have one key to match when initializing Fuse,
120 | // matches will only have 1 item
121 | highlightText(item.nameEl, i.matches[0])
122 | })
123 | }
124 |
125 | function highlightText(el, match) {
126 | // console.log('match', match, el)
127 | // get the longest part
128 | match.indices.sort((a, b) => (b[1] - b[0]) - (a[1] - a[0]))
129 | const pos = match.indices[0]
130 | const start = pos[0], end = pos[1] + 1
131 | const text = match.value
132 | el.innerHTML = `${text.slice(0, start)}${text.slice(start, end)}${text.slice(end, text.length)}`
133 | }
134 |
135 | export function initKeyboardSearch() {
136 | loadSearchItems()
137 | document.addEventListener('keydown', handleKeyPress);
138 | }
139 |
--------------------------------------------------------------------------------
/js/themer.js:
--------------------------------------------------------------------------------
1 | const setValue = (property, value) => {
2 | if (value) {
3 | document.documentElement.style.setProperty(`--${property}`, value);
4 |
5 | const input = document.querySelector(`#${property}`);
6 | if (input) {
7 | value = value.replace("px", "");
8 | input.value = value;
9 | }
10 | }
11 | };
12 |
13 | const setValueFromLocalStorage = (property) => {
14 | let value = localStorage.getItem(property);
15 | setValue(property, value);
16 | };
17 |
18 | const setTheme = (options) => {
19 | for (let option of Object.keys(options)) {
20 | const property = option;
21 | const value = options[option];
22 |
23 | setValue(property, value);
24 | localStorage.setItem(property, value);
25 | }
26 | };
27 |
28 | export function loadTheme() {
29 | setValueFromLocalStorage("color-background");
30 | setValueFromLocalStorage("color-text-pri");
31 | setValueFromLocalStorage("color-text-acc");
32 | }
33 |
34 | export function bindThemeButtons() {
35 | const dataThemeButtons = document.querySelectorAll("[data-theme]");
36 |
37 | for (let i = 0; i < dataThemeButtons.length; i++) {
38 | dataThemeButtons[i].addEventListener("click", () => {
39 | const theme = dataThemeButtons[i].dataset.theme;
40 |
41 | switch (theme) {
42 | case "blackboard":
43 | setTheme({
44 | "color-background": "#1a1a1a",
45 | "color-text-pri": "#FFFDEA",
46 | "color-text-acc": "#5c5c5c",
47 | });
48 | return;
49 |
50 | case "gazette":
51 | setTheme({
52 | "color-background": "#F2F7FF",
53 | "color-text-pri": "#000000",
54 | "color-text-acc": "#5c5c5c",
55 | });
56 | return;
57 |
58 | case "espresso":
59 | setTheme({
60 | "color-background": "#21211F",
61 | "color-text-pri": "#D1B59A",
62 | "color-text-acc": "#4E4E4E",
63 | });
64 | return;
65 |
66 | case "cab":
67 | setTheme({
68 | "color-background": "#F6D305",
69 | "color-text-pri": "#1F1F1F",
70 | "color-text-acc": "#424242",
71 | });
72 | return;
73 |
74 | case "cloud":
75 | setTheme({
76 | "color-background": "#f1f2f0",
77 | "color-text-pri": "#35342f",
78 | "color-text-acc": "#37bbe4",
79 | });
80 | return;
81 |
82 | case "lime":
83 | setTheme({
84 | "color-background": "#263238",
85 | "color-text-pri": "#AABBC3",
86 | "color-text-acc": "#aeea00",
87 | });
88 | return;
89 |
90 | case "white":
91 | setTheme({
92 | "color-background": "#ffffff",
93 | "color-text-pri": "#222222",
94 | "color-text-acc": "#dddddd",
95 | });
96 | return;
97 |
98 | case "tron":
99 | setTheme({
100 | "color-background": "#242B33",
101 | "color-text-pri": "#EFFBFF",
102 | "color-text-acc": "#6EE2FF",
103 | });
104 | return;
105 |
106 | case "blues":
107 | setTheme({
108 | "color-background": "#2B2C56",
109 | "color-text-pri": "#EFF1FC",
110 | "color-text-acc": "#6677EB",
111 | });
112 | return;
113 |
114 | case "passion":
115 | setTheme({
116 | "color-background": "#f5f5f5",
117 | "color-text-pri": "#12005e",
118 | "color-text-acc": "#8e24aa",
119 | });
120 | return;
121 |
122 | case "chalk":
123 | setTheme({
124 | "color-background": "#263238",
125 | "color-text-pri": "#AABBC3",
126 | "color-text-acc": "#FF869A",
127 | });
128 | return;
129 |
130 | case "paper":
131 | setTheme({
132 | "color-background": "#F8F6F1",
133 | "color-text-pri": "#4C432E",
134 | "color-text-acc": "#AA9A73",
135 | });
136 | return;
137 |
138 | case "initial":
139 | setTheme({
140 | "color-background": "initial",
141 | "color-text-pri": "initial",
142 | "color-text-acc": "initial",
143 | });
144 | return;
145 | }
146 | });
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/data.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "apps" : [
3 | {
4 | "name": "Cloud",
5 | "items": [
6 | {"name":"CloudCMD","url":"http://files.example.com","icon":"folder-multiple-outline"},
7 | {"name":"Cockpit","url":"http://cp.example.com","icon":"airplane"},
8 | {"name":"Feedbin","url":"http://rss.example.com","icon":"rss"},
9 | {"name":"Filestash","url":"http://cloud.example.com","icon":"package"},
10 | {"name":"Minio","url":"http://minio.example.com","icon":"server"},
11 | {"name":"Mylar","url":"http://comics.example.com","icon":"book-open-variant"},
12 | {"name":"Nextcloud","url":"http://cloud.example.com","icon":"weather-cloudy"},
13 | {"name":"Ombi","url":"http://request.example.com","icon":"file-find-outline"},
14 | {"name":"Pi-hole","url":"http://pihole.example.com","icon":"do-not-disturb"},
15 | {"name":"Portainer","url":"http://port1.example.com","icon":"docker"},
16 | {"name":"Stackedit","url":"http://md.example.com","icon":"markdown"},
17 | {"name":"Ubooquity","url":"http://opds.example.com","icon":"library-shelves"}
18 | ]
19 | },
20 | {
21 | "name": "TV",
22 | "items": [
23 | {"name":"Plex","url":"http://play.example.com","icon":"plex"},
24 | {"name":"Radarr","url":"http://movies.example.com","icon":"filmstrip"},
25 | {"name":"Sonarr","url":"http://tv.example.com","icon":"television-box"},
26 | {"name":"Bazarr","url":"http://subs.example.com","icon":"message-video"},
27 | {"name":"Jackett","url":"http://jackett.example.com","icon":"tshirt-crew-outline"},
28 | {"name":"Lidarr","url":"http://music.example.com","icon":"music"},
29 | {"name":"Transmission","url":"http://dl.example.com","icon":"progress-download"},
30 | {"name":"Youtube-DL","url":"http://yt.example.com","icon":"youtube"}
31 | ]
32 | }
33 | ],
34 | "bookmarks" : [
35 | {
36 | "category": "Communicate",
37 | "links": [
38 | {
39 | "name": "Discord",
40 | "url": "https://discord.com"
41 | },
42 | {
43 | "name": "Gmail",
44 | "url": "http://gmail.com"
45 | },
46 | {
47 | "name": "Slack",
48 | "url": "https://slack.com/signin"
49 | }
50 | ]
51 | },
52 | {
53 | "category": "Cloud",
54 | "links": [
55 | {
56 | "name": "Box",
57 | "url": "https://box.com"
58 | },
59 | {
60 | "name": "Dropbox",
61 | "url": "https://dropbox.com"
62 | },
63 | {
64 | "name": "Drive",
65 | "url": "https://drive.google.com"
66 | }
67 | ]
68 | },
69 | {
70 | "category": "Design",
71 | "links": [
72 | {
73 | "name": "Awwwards",
74 | "url": "https://awwwards.com"
75 | },
76 | {
77 | "name": "Dribbble",
78 | "url": "https://dribbble.com"
79 | },
80 | {
81 | "name": "Muz.li",
82 | "url": "https://medium.muz.li/"
83 | }
84 | ]
85 | },
86 | {
87 | "category": "Dev",
88 | "links": [
89 | {
90 | "name": "Codepen",
91 | "url": "https://codepen.io/"
92 | },
93 | {
94 | "name": "Devdocs",
95 | "url": "https://devdocs.io"
96 | },
97 | {
98 | "name": "Devhints",
99 | "url": "https://devhints.io"
100 | }
101 | ]
102 | },
103 | {
104 | "category": "Lifestyle",
105 | "links": [
106 | {
107 | "name": "Design Milk",
108 | "url": "https://design-milk.com/category/interior-design/"
109 | },
110 | {
111 | "name": "Dwell",
112 | "url": "https://www.dwell.com/"
113 | },
114 | {
115 | "name": "Freshome",
116 | "url": "https://www.mymove.com/freshome/"
117 | }
118 | ]
119 | },
120 | {
121 | "category": "Media",
122 | "links": [
123 | {
124 | "name": "Spotify",
125 | "url": "http://browse.spotify.com"
126 | },
127 | {
128 | "name": "Trakt",
129 | "url": "http://trakt.tv"
130 | },
131 | {
132 | "name": "YouTube",
133 | "url": "https://youtube.com/feed/subscriptions"
134 | }
135 | ]
136 | },
137 | {
138 | "category": "Reading",
139 | "links": [
140 | {
141 | "name": "Instapaper",
142 | "url": "https://www.instapaper.com/u"
143 | },
144 | {
145 | "name": "Medium",
146 | "url": "http://medium.com"
147 | },
148 | {
149 | "name": "Reddit",
150 | "url": "http://reddit.com"
151 | }
152 | ]
153 | },
154 | {
155 | "category": "Tech",
156 | "links": [
157 | {
158 | "name": "TheNextWeb",
159 | "url": "https://thenextweb.com/"
160 | },
161 | {
162 | "name": "The Verge",
163 | "url": "https://theverge.com/"
164 | },
165 | {
166 | "name": "MIT Technology Review",
167 | "url": "https://www.technologyreview.com/"
168 | }
169 | ]
170 | }
171 | ]
172 | }
173 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SUI2
2 |
3 | *a startpage for your server and / or new tab page*
4 |
5 | Originally forked from [sui](https://github.com/jeroenpardon/sui), sui2 adds
6 | new features like keyboard navigation and PWA to boost your productivity.
7 | It's a complete refactor, brings new technologies for easier development & deployment.
8 |
9 | See how keyboard navigation works in action:
10 |
11 |
12 |
13 |
14 | ## Deploy to any static hosting
15 |
16 | sui2 uses Vite to build a staic website, which means it's nothing but vanilla HTML/CSS/JavaScript that could be deployed to anywhere you want.
17 |
18 | To build the project, simply follow the steps below.
19 |
20 | 1. Install dependencies: `npm i`
21 | 2. Create you own `data.json`
22 |
23 | sui2 get all the data it requires from `data.json`, you can make a copy from `data.example.json`, and then edit it with your own applications and bookmarks.
24 | 3. Build the result: `npm run build`
25 |
26 | The result will be stored in the `dist` folder
27 | 4. Upload to a static hosting.
28 |
29 | There are various hosting services like GitHub Pages, Cloudflare Pages, Netlify.
30 | Examples will be documented later on.
31 |
32 | If you are happy with the look and functionality of sui2, it is recommended to use this project as a submodule rather than fork it. Please checkout [reorx/start](https://github.com/reorx/start) as an example for how to use it in another project, and how to build with GitHub Actions and deploy to Cloudflare Pages.
33 |
34 | ## Deploy using Docker
35 |
36 | > Notice: to make the preview page in live editor work more predictable, Docker image does not provide PWA support
37 |
38 | sui2 provides a Docker image that runs a NodeJS server,
39 | which not only servers the startpage directly,
40 | but also gives you an interface to edit and build the startpage lively.
41 |
42 | 
43 |
44 | The image is hosted on Docker hub at: [reorx/sui2](https://hub.docker.com/r/reorx/sui2)
45 |
46 | Run the following command to get started:
47 |
48 | ```bash
49 | docker run --rm -t -p 3000:3000 -v data:/data reorx/sui2
50 | ```
51 |
52 | Command explained:
53 |
54 | - `-p 3000:3000`: the server runs on port 3000, you need to specify the port on host to expose, if you want to access it from 5000, you can change the argument to `-p 5000:3000`
55 | - `-v data:/data`: you need to attach a volume to `/data`, which stores the config and static resources of the startpage
56 |
57 | After the container is alive, open `http://DOCKER_HOST:3000/` to see the initial startpage.
58 |
59 | For the live editor, open `http//DOCKER_HOST:3000/editor/`, there's no link for it on the startpage.
60 |
61 | Checkout the configuration file [fly.toml](https://github.com/reorx/sui2/blob/master/fly.toml) as an example for how to deploy the Docker image to fly.io
62 |
63 | ### Build Docker Image
64 |
65 | Currently, the image has only amd64 and arm64 variants, if your architecture is not one of these,
66 | please build the image by yourself, simply by running:
67 |
68 | ```
69 | docker buildx build -t sui2 .
70 | ```
71 |
72 | Notice that BuildKit (buildx) must be used to get the `TARGETARCH` argument,
73 | see [Automatic platform ARGs in the global scope](https://docs.docker.com/engine/reference/builder/#automatic-platform-args-in-the-global-scope)
74 |
75 |
76 | ## `data.json` editing
77 |
78 | There's a full example in [data.example.json](https://github.com/reorx/sui2/blob/master/data.example.json),
79 | it's self explanatory so I'm not going to write too much about it, maybe a json schema will be created as a supplement in the future.
80 |
81 | The only thing worth mentioning here is the `icon` attribute,
82 | it uses the [MDI icon set from Iconify](https://icon-sets.iconify.design/mdi/), you can find any icon you like in this page, and use the name after `mdi:` as the value for the `icon` attribute. For example `mdi:bread-slice` should be used as `"icon": "bread-slice"` in `data.json`.
83 |
84 | ## Development
85 |
86 | Developing the startpage is easy, first clone the project, then run the following:
87 |
88 | ```bash
89 | npm install
90 |
91 | # start vite dev server
92 | npm run dev
93 | ```
94 |
95 | Developing the live-server is a little bit tricky, `live-server/` is an independent package with an express server and another vite frontend.
96 |
97 | ```bash
98 | cd live-server
99 | npm install
100 |
101 | # start the express server on port 3000
102 | npm run dev-backend
103 |
104 | # open another shell, then start vite dev server
105 | npm run dev
106 | ```
107 |
108 | The output of `npm run dev` looks like this:
109 |
110 | ```
111 | ➜ Local: http://localhost:5173/editor/
112 | ```
113 |
114 | You can now open this URL to start developing live-server.
115 | The fetch requests of `/api` and `/preview` on this page will be proxied to
116 | the express server on port 3000. The default data folder is at `live-server/data/`.
117 |
118 | ## TODO
119 |
120 | Some other features I plan to work in the future, PRs are welcome.
121 |
122 | - [ ] Custom theme editor
123 | - [ ] Support dynamically render the page from `data.json`. This makes it possible to host a sui2 distribution that is changable without the building tools.
124 | - [ ] A chrome extension that shows sui2 in a popup.
125 | - [ ] Add new tab support for the chrome extension.
126 |
127 | ## Donation
128 |
129 | If you think this project is enjoyable to use, or saves some time,
130 | consider giving me a cup of coffee :)
131 |
132 | - [GitHub Sponsors - reorx](https://github.com/sponsors/reorx/)
133 | - [Ko-Fi - reorx](https://ko-fi.com/reorx)
134 |
--------------------------------------------------------------------------------
/scss/styles.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --edge-gap: 8px;
3 | --color-link-hover-bg: rgba(205, 205, 205, 0.5);
4 | --color-text-matched: rgb(188, 29, 29);
5 | }
6 |
7 | html {
8 | box-sizing: border-box;
9 | }
10 |
11 | html,
12 | body {
13 | background-color: var(--color-background);
14 | color: var(--color-text-pri);
15 | font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Roboto, sans-serif;
16 | font-size: 14px;
17 | height: auto;
18 | letter-spacing: -0.012em;
19 | margin: 0;
20 | padding: 0;
21 | -webkit-font-smoothing: antialiased;
22 | width: 100vw;
23 | }
24 |
25 | *,
26 | *:before,
27 | *:after {
28 | box-sizing: inherit;
29 | }
30 |
31 | /* TEXT STYLES */
32 |
33 | h1,
34 | h2 {
35 | margin: 0;
36 | padding: 0;
37 | text-align: left;
38 | }
39 |
40 | h3,
41 | h4 {
42 | text-transform: uppercase;
43 | }
44 |
45 | h1 {
46 | font-size: 4em;
47 | font-weight: 700;
48 | }
49 |
50 | h2 {
51 | font-size: 16px;
52 | height: 30px;
53 | font-weight: 400;
54 | }
55 |
56 | h3 {
57 | font-size: 20px;
58 | font-weight: 900;
59 | margin: 0.5em 0 1em;
60 | }
61 |
62 | h4 {
63 | font-size: 1.1em;
64 | font-weight: 300;
65 | margin: 0.5em 0;
66 | }
67 |
68 | a {
69 | color: var(--color-text-pri);
70 | text-decoration: none;
71 | }
72 |
73 | a:hover {
74 | text-decoration: underline;
75 | text-decoration-color: var(--color-text-acc);
76 | text-decoration-skip: true;
77 | }
78 |
79 | /* LAYOUT */
80 |
81 | #container {
82 | display: grid;
83 | grid-column-gap: 20px;
84 | grid-row-gap: 3vh;
85 | grid-template-columns: 1fr;
86 | grid-template-rows: auto;
87 | align-items: stretch;
88 | justify-items: stretch;
89 | margin-left: auto;
90 | margin-right: auto;
91 | margin-top: 5vh;
92 | width: 60%;
93 | min-width: 800px;
94 | }
95 |
96 | /* SECTIONS */
97 |
98 | #keyword {
99 | position: fixed;
100 | width: 100%;
101 | top: 0;
102 | height: 30px;
103 | text-align: center;
104 | padding-top: var(--edge-gap);
105 |
106 | span {
107 | font-size: 1.5em;
108 | background-color: var(--color-link-hover-bg);
109 | display: inline-block;
110 | padding: 3px 5px;
111 | }
112 | }
113 |
114 | #header_date {
115 | .time {
116 | margin-inline-start: 3em;
117 | float: right;
118 | }
119 | }
120 |
121 | .apps_loop {
122 | display: grid;
123 | grid-column-gap: 0px;
124 | grid-row-gap: 0px;
125 | grid-template-columns: 1fr 1fr 1fr 1fr;
126 | grid-template-rows: 64px;
127 | padding-bottom: var(--module-spacing);
128 | }
129 |
130 | .matched {
131 | background-color: var(--color-link-hover-bg);
132 |
133 | em {
134 | font-style: normal;
135 | font-weight: 700;
136 | color: var(--color-text-matched);
137 | }
138 | }
139 |
140 | .apps_item_wrap {
141 | display: flex;
142 | flex-direction: row;
143 | flex-wrap: wrap;
144 | margin: 0;
145 |
146 | .apps_item {
147 | display: flex;
148 | padding: var(--edge-gap);
149 | margin-left: calc(0px - var(--edge-gap));
150 | height: 55px;
151 |
152 | &:hover,
153 | &:active {
154 | background-color: var(--color-link-hover-bg);
155 | text-decoration: none;
156 | }
157 | &:hover .name {
158 | text-decoration: underline;
159 | }
160 | }
161 |
162 | .apps_icon {
163 | margin-right: 1em;
164 | display: flex;
165 | flex-direction: column;
166 | justify-content: center;
167 | svg {
168 | width: 32px;
169 | height: 32px;
170 | }
171 | }
172 |
173 | .apps_text {
174 | display: flex;
175 | flex-direction: column;
176 | justify-content: center;
177 | flex: 1;
178 | overflow: hidden;
179 | line-height: 1.3em;
180 | color: var(--color-text-acc);
181 | font-weight: 500;
182 |
183 | .domain {
184 | font-size: 0.8em;
185 | font-weight: 400;
186 | }
187 | }
188 | }
189 |
190 | #links_loop {
191 | display: grid;
192 | flex-wrap: nowrap;
193 | grid-column-gap: 20px;
194 | grid-row-gap: 0px;
195 | grid-template-columns: 1fr 1fr 1fr 1fr;
196 | grid-template-rows: auto;
197 | }
198 |
199 | .links_category {
200 | line-height: 1.5rem;
201 | margin-bottom: 2em;
202 | -webkit-font-smoothing: antialiased;
203 |
204 | h4 {
205 | color: var(--color-text-acc);
206 | }
207 |
208 | a {
209 | display: block;
210 | line-height: 2;
211 | }
212 | }
213 |
214 | /* MODAL */
215 |
216 | @import "modal";
217 |
218 | /* THEMING */
219 |
220 | @import "theme";
221 |
222 | /* MEDIA QUERIES */
223 |
224 | @media screen and (max-width: 960px) {
225 | #container {
226 | // display: grid;
227 | // grid-column-gap: 10px;
228 | // grid-row-gap: 0px;
229 | // grid-template-columns: 1fr;
230 | // grid-template-rows: 80px auto;
231 | width: 90%;
232 | min-width: initial;
233 | }
234 |
235 | .apps_loop {
236 | grid-template-columns: 1fr 1fr 1fr;
237 | width: 100vw;
238 | }
239 |
240 | #links_loop {
241 | grid-template-columns: 1fr 1fr 1fr;
242 | }
243 |
244 | #modal > div {
245 | margin-left: auto;
246 | margin-right: auto;
247 | margin-top: 5vh;
248 | width: 90%;
249 | }
250 | }
251 |
252 | @media screen and (max-width: 667px) {
253 | html {
254 | font-size: calc(16px + 6 * ((100vw - 320px) / 680));
255 | }
256 |
257 | .apps_loop {
258 | grid-column-gap: 0px;
259 | grid-row-gap: 0px;
260 | grid-template-columns: 1fr 1fr;
261 | width: 100vw;
262 | }
263 |
264 | #links_loop {
265 | flex-wrap: nowrap;
266 | display: grid;
267 | grid-column-gap: 20px;
268 | grid-row-gap: 0px;
269 | grid-template-columns: 1fr 1fr;
270 | grid-template-rows: auto;
271 | }
272 | }
273 |
--------------------------------------------------------------------------------