├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── docker-image.yml │ └── gh-pages.yml ├── .gitignore ├── .node-version ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── data.example.json ├── fly.toml ├── icons.js ├── images └── live-editor.png ├── index.html ├── js ├── date.js ├── main.js ├── search.js └── themer.js ├── live-server ├── app.js ├── editor │ ├── index.html │ ├── main.js │ └── styles.css ├── package.json └── vite.config.js ├── package.json ├── public ├── 404.html ├── icon-512.png └── robots.txt ├── scss ├── _modal.scss ├── _theme.scss └── styles.scss └── vite.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | **/node_modules 3 | **/dist 4 | live-server/data 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | dist/ 3 | data.json 4 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v16.13.0 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ![SUI2 Live Editor](images/live-editor.png) 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = `${replaceIDs(renderData.body)}`; 34 | return svg 35 | } 36 | -------------------------------------------------------------------------------- /images/live-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reorx/sui2/a1631c4f46e28352f13727601f9fe22f94f63fcf/images/live-editor.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SUI2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 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 | 92 |
93 | 94 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /live-server/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SUI2 Live Editor 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

SUI Live Editor

16 | 17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reorx/sui2/a1631c4f46e28352f13727601f9fe22f94f63fcf/public/icon-512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------