Initial commit for the web interface

This commit is contained in:
nemunaire 2022-10-01 19:37:12 +02:00
commit 4eea7769ff
36 changed files with 1186 additions and 0 deletions

20
ui/.eslintrc.cjs Normal file
View file

@ -0,0 +1,20 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

8
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example

1
ui/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

6
ui/.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100
}

38
ui/README.md Normal file
View file

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm init svelte@next
# create a new project in my-app
npm init svelte@next my-app
```
> Note: the `@next` is temporary
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then:
```bash
npm run build
```
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.

32
ui/assets-dev.go Normal file
View file

@ -0,0 +1,32 @@
//go:build dev
// +build dev
package ui
import (
"flag"
"net/http"
"os"
"path/filepath"
)
var (
Assets http.FileSystem
StaticDir string = "ui/"
)
func init() {
flag.StringVar(&StaticDir, "static", StaticDir, "Directory containing static files")
}
func sanitizeStaticOptions() error {
StaticDir, _ = filepath.Abs(StaticDir)
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
StaticDir, _ = filepath.Abs(filepath.Join(filepath.Dir(os.Args[0]), "ui"))
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
return err
}
}
Assets = http.Dir(StaticDir)
return nil
}

28
ui/assets.go Normal file
View file

@ -0,0 +1,28 @@
//go:build !dev
// +build !dev
package ui
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:build
var _assets embed.FS
var Assets http.FileSystem
func init() {
sub, err := fs.Sub(_assets, "build")
if err != nil {
log.Fatal("Unable to cd to ui/build directory:", err)
}
Assets = http.FS(sub)
}
func sanitizeStaticOptions() error {
return nil
}

41
ui/package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "reveil",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"package": "vite package",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^1.0.0-next.18",
"@sveltejs/adapter-static": "^1.0.0-next.26",
"@sveltejs/kit": "^1.0.0-next.260",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"bootstrap": "^5.1.3",
"bootstrap-icons": "^1.8.0",
"bootswatch": "^5.1.3",
"eslint": "^8.0.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.4.1",
"prettier-plugin-svelte": "^2.6.0",
"svelte": "^3.46.4",
"svelte-check": "^2.4.2",
"svelte-preprocess": "^4.10.2",
"tslib": "^2.3.1",
"typescript": "^4.5.5"
},
"type": "module",
"dependencies": {
"sass": "^1.49.7",
"sass-loader": "^13.0.0",
"sveltestrap": "^5.8.3",
"vite": "^3.0.0"
}
}

77
ui/routes.go Normal file
View file

@ -0,0 +1,77 @@
package ui
import (
"io"
"net/http"
"net/url"
"path"
"github.com/gin-gonic/gin"
"git.nemunai.re/nemunaire/reveil/config"
)
func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if cfg.DevProxy != "" {
// Forward to the Vue dev proxy
return func(c *gin.Context) {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else if resp, err := http.DefaultClient.Do(r); err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
} else {
defer resp.Body.Close()
for key := range resp.Header {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
}
}
} else if forced_url != "" {
// Serve forced_url
return func(c *gin.Context) {
c.FileFromFS(forced_url, Assets)
}
} else {
// Serve requested file
return func(c *gin.Context) {
c.FileFromFS(c.Request.URL.Path, Assets)
}
}
}
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
router.GET("/node_modules/*_", serveOrReverse("", cfg))
router.GET("/@vite/*_", serveOrReverse("", cfg))
router.GET("/@fs/*_", serveOrReverse("", cfg))
router.GET("/src/*_", serveOrReverse("", cfg))
}
router.GET("/", serveOrReverse("", cfg))
router.GET("/alarms", serveOrReverse("/", cfg))
router.GET("/settings", serveOrReverse("/", cfg))
router.GET("/routines", serveOrReverse("/", cfg))
router.GET("/musiks", serveOrReverse("/", cfg))
router.GET("/history", serveOrReverse("/", cfg))
router.GET("/_app/*_", serveOrReverse("", cfg))
router.GET("/img/*_", serveOrReverse("", cfg))
router.GET("/favicon.ico", serveOrReverse("", cfg))
router.GET("/manifest.json", serveOrReverse("", cfg))
router.GET("/service-worker.js", serveOrReverse("", cfg))
}

19
ui/src/app.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="fr" class="d-flex flex-column mh-100 h-100">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff"/>
<link rel="apple-touch-icon" sizes="192x192" href="/img/apple-touch-icon.png">
<meta name="author" content="nemucorp">
<meta name="robots" content="none">
<base href="/">
%sveltekit.head%
</head>
<body class="flex-fill d-flex flex-column">
<div class="flex-fill d-flex flex-column justify-content-between" style="min-height: 100%">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,81 @@
<script>
import {
Icon,
Navbar,
NavbarBrand,
Nav,
NavItem,
NavLink,
} from 'sveltestrap';
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
export let activemenu = '';
export { className as class };
let className = '';
</script>
<Navbar class={className} color="primary" dark expand="xs" style="overflow-x: auto">
<NavbarBrand href="." class="d-none d-md-block" style="padding: 0; margin: -.5rem 0;">
Réveil
</NavbarBrand>
<Nav navbar>
<NavItem>
<NavLink
active={activemenu === 'recipes'}
class="text-center"
href="alarms"
>
<Icon name="alarm-fill" /><br class="d-inline d-md-none">
Réveils
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="musiks"
class="text-center"
active={activemenu === 'explore'}
>
<Icon name="music-note-list" /><br class="d-inline d-md-none">
Musiques
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="routines"
class="text-center"
active={activemenu === 'routines'}
>
<Icon name="activity" /><br class="d-inline d-md-none">
Routines
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="history"
class="text-center"
active={activemenu === 'history'}
>
<Icon name="clipboard-pulse" /><br class="d-inline d-md-none">
Historique
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="settings"
class="text-center"
active={activemenu === 'settings'}
>
<Icon name="gear-fill" /><br class="d-inline d-md-none">
Paramètres
</NavLink>
</NavItem>
</Nav>
<Nav class="ms-auto text-light" navbar>
<NavItem>
{#await version then v}
{v.version}
{/await}
</NavItem>
</Nav>
</Navbar>

View file

@ -0,0 +1,22 @@
<script>
import {
Toast,
ToastBody,
ToastHeader,
} from 'sveltestrap';
import { ToastsStore } from '../stores/toasts';
</script>
<div class="toast-container position-absolute top-0 end-0 p-3">
{#each $ToastsStore.toasts as toast}
<Toast>
<ToastHeader toggle={toast.close} icon={toast.color}>
{#if toast.title}{toast.title}{:else}Gustus{/if}
</ToastHeader>
<ToastBody>
{toast.msg}
</ToastBody>
</Toast>
{/each}
</div>

1
ui/src/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="@sveltejs/kit" />

34
ui/src/reveil.scss Normal file
View file

@ -0,0 +1,34 @@
// Your variable overrides can go here, e.g.:
// $h1-font-size: 3rem;
//$primary: #ff485a;
//$secondary: #ff7b88;
$blue: #2a9fd6;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #c00;
$orange: #fd7e14;
$yellow: #f80;
$green: #77b300;
$teal: #20c997;
$cyan: #93c;
$primary: $pink;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$min-contrast-ratio: 2.25;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$link-color: $primary;
$navbar-padding-y: 0;
$nav-link-padding-y: 0.2rem;
@import "bootstrap/scss/bootstrap";

2
ui/src/routes/+layout.js Normal file
View file

@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;

View file

@ -0,0 +1,34 @@
<script>
import '../reveil.scss'
import "bootstrap-icons/font/bootstrap-icons.css";
import {
//Styles,
} from 'sveltestrap';
import Header from '../components/Header.svelte';
import Toaster from '../components/Toaster.svelte';
</script>
<svelte:head>
<title>Réveil</title>
</svelte:head>
<!--Styles /-->
<Header
class="d-none d-lg-flex py-2"
/>
<div class="flex-fill pb-4 pb-lg-2">
<slot></slot>
</div>
<Toaster />
<Header
class="d-flex d-lg-none py-1 fixed-bottom"
/>
<style>
:global(a.badge) {
text-decoration: none;
}
</style>

View file

@ -0,0 +1,48 @@
<script>
import {
Container,
Icon,
} from 'sveltestrap';
</script>
<Container class="mh-100 h-100 d-flex flex-column justify-content-center text-center">
<figure>
<blockquote class="blockquote text-muted">
<p class="display-6">A well-known quote, contained in a blockquote element.</p>
</blockquote>
<figcaption class="blockquote-footer">
Someone famous in <cite title="Source Title">Source Title</cite>
</figcaption>
</figure>
<div class="display-5 mb-4">
Prochain réveil&nbsp;: demain matin à 7h10 (dans 5 cycles)
</div>
<div class="d-flex gap-3 justify-content-center">
<button class="btn btn-primary">
<Icon name="node-plus" />
Programmer un nouveau réveil
</button>
<button class="btn btn-info">
<Icon name="node-plus" />
5 cycles
</button>
<button class="btn btn-info">
<Icon name="node-plus" />
6 cycles
</button>
</div>
<div class="d-flex gap-3 mt-3 justify-content-center">
<button class="btn btn-outline-info">
<Icon name="skip-end-fill" />
Chanson suivante
</button>
<button class="btn btn-danger">
<Icon name="stop-circle" />
Éteindre le réveil
</button>
<button class="btn btn-outline-info">
<Icon name="fast-forward-fill" />
Passer cette étape de la routine
</button>
</div>
</Container>

80
ui/src/service-worker.ts Normal file
View file

@ -0,0 +1,80 @@
/// <reference lib="webworker" />
import { build, files, timestamp } from '$service-worker';
const worker = (self as unknown) as ServiceWorkerGlobalScope;
const FILES = `cache${timestamp}`;
// `build` is an array of all the files generated by the bundler,
// `files` is an array of everything in the `static` directory
const to_cache = build.concat(files);
const staticAssets = new Set(to_cache);
worker.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(FILES)
.then((cache) => cache.addAll(to_cache))
.then(() => {
worker.skipWaiting();
})
);
});
worker.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(async (keys) => {
// delete old caches
for (const key of keys) {
if (key !== FILES) await caches.delete(key);
}
worker.clients.claim();
})
);
});
/**
* Fetch the asset from the network and store it in the cache.
* Fall back to the cache if the user is offline.
*/
async function fetchAndCache(request: Request) {
const cache = await caches.open(`offline${timestamp}`);
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch (err) {
const response = await cache.match(request);
if (response) return response;
throw err;
}
}
worker.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET' || event.request.headers.has('range')) return;
const url = new URL(event.request.url);
// don't try to handle e.g. data: URIs
const isHttp = url.protocol.startsWith('http');
const isDevServerRequest =
url.hostname === self.location.hostname && url.port !== self.location.port;
const isStaticAsset = url.host === self.location.host && staticAssets.has(url.pathname);
const skipBecauseUncached = event.request.cache === 'only-if-cached' && !isStaticAsset;
if (isHttp && !isDevServerRequest && !skipBecauseUncached) {
event.respondWith(
(async () => {
// always serve static files and bundler-generated assets from cache.
// if your application has other URLs with data that will never change,
// set this variable to true for them and they will only be fetched once.
const cachedAsset = isStaticAsset && (await caches.match(event.request));
return cachedAsset || fetchAndCache(event.request);
})()
);
}
});

41
ui/src/stores/toasts.js Normal file
View file

@ -0,0 +1,41 @@
import { writable } from 'svelte/store';
function createToastsStore() {
const { subscribe, update } = writable({toasts: []});
const addToast = (o) => {
o.timestamp = new Date();
o.close = () => {
update((i) => {
i.toasts = i.toasts.filter((j) => {
return !(j.title === o.title && j.msg === o.msg && j.timestamp === o.timestamp)
});
return i;
});
}
update((i) => {
i.toasts.unshift(o);
return i;
});
o.cancel = setTimeout(o.close, o.dismiss?o.dismiss:5000);
};
const addErrorToast = (o) => {
if (!o.title) o.title = 'Une erreur est survenue !';
if (!o.color) o.color = 'danger';
return addToast(o);
};
return {
subscribe,
addToast,
addErrorToast,
};
}
export const ToastsStore = createToastsStore();

20
ui/static/manifest.json Normal file
View file

@ -0,0 +1,20 @@
{
"manifest_version": 2,
"short_name": "Gustus",
"name": "Gustus",
"version": "0.1",
"author": "nemucorp",
"start_url": "/",
"icons": [
{
"src": "img/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"background_color": "#d62a49",
"display": "standalone",
"scope": "/",
"theme_color": "#ffffff",
"description": "Retrouvez facilement toutes vos recettes préférées"
}

20
ui/svelte.config.js Normal file
View file

@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-static';
import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: preprocess(),
kit: {
adapter: adapter({
fallback: 'index.html'
}),
paths: {
// base: '{{.urlbase}}',
}
}
};
export default config;

32
ui/tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"moduleResolution": "node",
"module": "es2020",
"lib": ["es2020", "DOM"],
"target": "es2020",
/**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"allowJs": true,
"checkJs": true,
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
}

8
ui/vite.config.js Normal file
View file

@ -0,0 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
};
export default config;