Add basic frontend & account sidebar
This commit is contained in:
parent
21cac8d2ed
commit
1194e36e6e
26 changed files with 4399 additions and 38 deletions
152
.eslintrc.js
Normal file
152
.eslintrc.js
Normal file
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Hack to automatically load globally installed eslint modules
|
||||
* on Archlinux systems placed in /usr/lib/node_modules
|
||||
*
|
||||
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
|
||||
*/
|
||||
|
||||
const Module = require('module')
|
||||
|
||||
const hacks = [
|
||||
'@babel/eslint-parser',
|
||||
'eslint-plugin-vue',
|
||||
]
|
||||
|
||||
const ModuleFindPath = Module._findPath
|
||||
Module._findPath = (request, paths, isMain) => {
|
||||
const r = ModuleFindPath(request, paths, isMain)
|
||||
if (!r && hacks.includes(request)) {
|
||||
return require.resolve(`/usr/lib/node_modules/${request}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/*
|
||||
* ESLint configuration derived as differences from eslint:recommended
|
||||
* with changes I found useful to ensure code quality and equal formatting
|
||||
* https://eslint.org/docs/user-guide/configuring
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
|
||||
extends: [
|
||||
'plugin:vue/recommended',
|
||||
'eslint:recommended', // https://eslint.org/docs/rules/
|
||||
],
|
||||
|
||||
globals: {
|
||||
process: true,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
parser: '@babel/eslint-parser',
|
||||
requireConfigFile: false,
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// required to lint *.vue files
|
||||
'vue',
|
||||
],
|
||||
|
||||
reportUnusedDisableDirectives: true,
|
||||
|
||||
root: true,
|
||||
|
||||
rules: {
|
||||
'array-bracket-newline': ['error', { multiline: true }],
|
||||
'array-bracket-spacing': ['error'],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
'arrow-parens': ['error', 'as-needed'],
|
||||
'arrow-spacing': ['error', { after: true, before: true }],
|
||||
'block-spacing': ['error'],
|
||||
'brace-style': ['error', '1tbs'],
|
||||
'camelcase': ['error'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'comma-spacing': ['error'],
|
||||
'comma-style': ['error', 'last'],
|
||||
'curly': ['error'],
|
||||
'default-case-last': ['error'],
|
||||
'default-param-last': ['error'],
|
||||
'dot-location': ['error', 'property'],
|
||||
'dot-notation': ['error'],
|
||||
'eol-last': ['error', 'always'],
|
||||
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
'function-paren-newline': ['error', 'multiline'],
|
||||
'generator-star-spacing': ['off'], // allow async-await
|
||||
'implicit-arrow-linebreak': ['error'],
|
||||
'indent': ['error', 2],
|
||||
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
|
||||
'keyword-spacing': ['error'],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lines-between-class-members': ['error'],
|
||||
'multiline-comment-style': ['warn'],
|
||||
'newline-per-chained-call': ['error'],
|
||||
'no-alert': ['error'],
|
||||
'no-console': ['off'],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-else-return': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extra-parens': ['error'],
|
||||
'no-implicit-coercion': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
|
||||
'no-promise-executor-return': ['error'],
|
||||
'no-return-assign': ['error'],
|
||||
'no-script-url': ['error'],
|
||||
'no-template-curly-in-string': ['error'],
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unreachable-loop': ['error'],
|
||||
'no-unsafe-optional-chaining': ['error'],
|
||||
'no-useless-return': ['error'],
|
||||
'no-var': ['error'],
|
||||
'no-warning-comments': ['error'],
|
||||
'no-whitespace-before-property': ['error'],
|
||||
'object-curly-newline': ['error', { consistent: true }],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'object-shorthand': ['error'],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'prefer-arrow-callback': ['error'],
|
||||
'prefer-const': ['error'],
|
||||
'prefer-object-spread': ['error'],
|
||||
'prefer-rest-params': ['error'],
|
||||
'prefer-template': ['error'],
|
||||
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
|
||||
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||
'require-atomic-updates': ['error'],
|
||||
'require-await': ['error'],
|
||||
'semi': ['error', 'never'],
|
||||
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
|
||||
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
'space-in-parens': ['error', 'never'],
|
||||
'space-infix-ops': ['error'],
|
||||
'space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||
'spaced-comment': ['warn', 'always'],
|
||||
'switch-colon-spacing': ['error'],
|
||||
'template-curly-spacing': ['error', 'never'],
|
||||
'unicode-bom': ['error', 'never'],
|
||||
'vue/new-line-between-multi-line-property': ['error'],
|
||||
'vue/no-empty-component-block': ['error'],
|
||||
'vue/no-reserved-component-names': ['error'],
|
||||
'vue/no-template-target-blank': ['error'],
|
||||
'vue/no-unused-properties': ['error'],
|
||||
'vue/no-unused-refs': ['error'],
|
||||
'vue/no-useless-mustaches': ['error'],
|
||||
'vue/order-in-components': ['off'], // Collides with sort-keys
|
||||
'vue/require-name-property': ['error'],
|
||||
'vue/v-for-delimiter-style': ['error'],
|
||||
'vue/v-on-function-call': ['error'],
|
||||
'wrap-iife': ['error'],
|
||||
'yoda': ['error'],
|
||||
},
|
||||
}
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
accounting
|
||||
node_modules
|
||||
pkg/frontend/assets/app.*
|
||||
pkg/frontend/assets/*.ttf
|
||||
pkg/frontend/assets/*.woff2
|
||||
test.db
|
2
.npmrc
Normal file
2
.npmrc
Normal file
|
@ -0,0 +1,2 @@
|
|||
@fortawesome:registry=https://npm.fontawesome.com/
|
||||
//npm.fontawesome.com/:_authToken=${FONTAWESOME_NPM_AUTH_TOKEN}
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
@ -0,0 +1,30 @@
|
|||
FROM golang:alpine as builder
|
||||
|
||||
COPY . /go/src/accounting
|
||||
WORKDIR /go/src/accounting
|
||||
|
||||
RUN set -ex \
|
||||
&& apk add --no-cache \
|
||||
git \
|
||||
make \
|
||||
nodejs \
|
||||
npm \
|
||||
&& make build
|
||||
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
LABEL maintainer "Knut Ahlers <knut@ahlers.me>"
|
||||
|
||||
RUN set -ex \
|
||||
&& apk --no-cache add \
|
||||
ca-certificates
|
||||
|
||||
COPY --from=builder /go/src/accounting/accounting /usr/local/bin/accounting
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/accounting"]
|
||||
CMD ["--"]
|
||||
|
||||
# vim: set ft=Dockerfile:
|
19
Makefile
Normal file
19
Makefile
Normal file
|
@ -0,0 +1,19 @@
|
|||
PORT := 5000
|
||||
|
||||
default: build
|
||||
|
||||
build: frontend
|
||||
go build \
|
||||
-ldflags "-X main.version=$(shell git describe --tags --always || echo dev)" \
|
||||
-mod=readonly \
|
||||
-trimpath
|
||||
|
||||
.PHONY: frontend
|
||||
frontend: node_modules
|
||||
node ci/build.mjs
|
||||
|
||||
node_modules:
|
||||
vault2env --key secret/jenkins/fontawesome -- npm ci --include=dev
|
||||
|
||||
run: frontend
|
||||
envrun -- go run . --listen=:$(PORT)
|
30
ci/build.mjs
Normal file
30
ci/build.mjs
Normal file
|
@ -0,0 +1,30 @@
|
|||
import esbuild from 'esbuild'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import vuePlugin from 'esbuild-plugin-vue3'
|
||||
|
||||
esbuild.build({
|
||||
assetNames: '[name]',
|
||||
bundle: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
|
||||
},
|
||||
entryPoints: ['frontend/main.js'],
|
||||
legalComments: 'none',
|
||||
loader: {
|
||||
'.ttf': 'file',
|
||||
'.woff2': 'file',
|
||||
},
|
||||
minify: true,
|
||||
outfile: 'pkg/frontend/assets/app.js',
|
||||
plugins: [
|
||||
sassPlugin(),
|
||||
vuePlugin(),
|
||||
],
|
||||
target: [
|
||||
'chrome87',
|
||||
'edge87',
|
||||
'es2020',
|
||||
'firefox84',
|
||||
'safari14',
|
||||
],
|
||||
})
|
280
frontend/components/accountsSidebar.vue
Normal file
280
frontend/components/accountsSidebar.vue
Normal file
|
@ -0,0 +1,280 @@
|
|||
<template>
|
||||
<div>
|
||||
<router-link
|
||||
to="/"
|
||||
class="d-flex align-items-center fs-5 mb-3 mb-md-0 me-md-auto text-start text-white text-decoration-none"
|
||||
>
|
||||
<i class="fas fa-fw fa-hand-holding-dollar me-2" />
|
||||
My Budget
|
||||
</router-link>
|
||||
<!--
|
||||
<router-link
|
||||
to="/"
|
||||
class="btn fs-5 text-start w-100"
|
||||
>
|
||||
<i class="fas fa-fw fa-chart-line" />
|
||||
Reports
|
||||
</router-link>
|
||||
-->
|
||||
<hr>
|
||||
<ul class="list-unstyled lh-lg ps-0">
|
||||
<li class="mb-1 fw-semibold">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-fw fa-credit-card me-1" /> Budget
|
||||
<span :class="{'ms-auto': true, 'text-danger': budgetSum < 0}">
|
||||
{{ budgetSum.toFixed(2) }} €
|
||||
</span>
|
||||
</div>
|
||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||
<li>
|
||||
<router-link
|
||||
v-for="acc in budgetAccounts"
|
||||
:key="acc.id"
|
||||
class="d-flex align-items-center text-white text-decoration-none"
|
||||
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
||||
>
|
||||
{{ acc.name }}
|
||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||
{{ acc.balance.toFixed(2) }} €
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mb-1 fw-semibold">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-fw fa-coin me-1" /> Tracking
|
||||
<span :class="{'ms-auto': true, 'text-danger': trackingSum < 0}">
|
||||
{{ trackingSum.toFixed(2) }} €
|
||||
</span>
|
||||
</div>
|
||||
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 ps-3 small">
|
||||
<li>
|
||||
<router-link
|
||||
v-for="acc in trackingAccounts"
|
||||
:key="acc.id"
|
||||
class="d-flex align-items-center text-white text-decoration-none"
|
||||
:to="{ name: 'account-transactions', params: { id: acc.id }}"
|
||||
>
|
||||
{{ acc.name }}
|
||||
<span :class="{'ms-auto': true, 'text-danger': acc.balance < 0}">
|
||||
{{ acc.balance.toFixed(2) }} €
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
class="btn btn-sm w-100"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#createAccountModal"
|
||||
>
|
||||
<i class="fas fa-fw fa-circle-plus mr-1" />
|
||||
Add Account
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="createAccountModal"
|
||||
ref="createAccountModal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
aria-labelledby="exampleModalLabel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1
|
||||
id="exampleModalLabel"
|
||||
class="modal-title fs-5"
|
||||
>
|
||||
Add Account
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="createAccountModalName"
|
||||
class="form-label"
|
||||
>Account Name</label>
|
||||
<input
|
||||
id="createAccountModalName"
|
||||
v-model.trim="modals.addAccount.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="createAccountModalType"
|
||||
class="form-label"
|
||||
>Account Type</label>
|
||||
<select
|
||||
v-model="modals.addAccount.type"
|
||||
class="form-select"
|
||||
>
|
||||
<option value="budget">
|
||||
Budget
|
||||
</option>
|
||||
<option value="tracking">
|
||||
Tracking
|
||||
</option>
|
||||
<option value="category">
|
||||
Category
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="createAccountModalBalance"
|
||||
class="form-label"
|
||||
>Starting Balance</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="createAccountModalBalance"
|
||||
v-model.number="modals.addAccount.startingBalance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-control"
|
||||
>
|
||||
<span class="input-group-text">€</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="addAccount"
|
||||
>
|
||||
<i class="fas fa-fw fa-circle-plus mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from 'bootstrap'
|
||||
|
||||
const startingBalanceAcc = '00000000-0000-0000-0000-000000000002'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
budgetAccounts() {
|
||||
const accs = (this.accounts || []).filter(acc => acc.type === 'budget')
|
||||
accs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return accs
|
||||
},
|
||||
|
||||
budgetSum() {
|
||||
return this.budgetAccounts.reduce((sum, acc) => sum + acc.balance, 0)
|
||||
},
|
||||
|
||||
trackingAccounts() {
|
||||
const accs = (this.accounts || []).filter(acc => acc.type === 'tracking')
|
||||
accs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return accs
|
||||
},
|
||||
|
||||
trackingSum() {
|
||||
return this.trackingAccounts.reduce((sum, acc) => sum + acc.balance, 0)
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
modals: {
|
||||
addAccount: {
|
||||
name: '',
|
||||
startingBalance: 0,
|
||||
type: 'budget',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
addAccount() {
|
||||
return fetch('/api/accounts', {
|
||||
body: JSON.stringify({
|
||||
name: this.modals.addAccount.name,
|
||||
type: this.modals.addAccount.type,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(account => {
|
||||
if (account.type === 'budget') {
|
||||
return fetch('/api/transactions', {
|
||||
body: JSON.stringify({
|
||||
account: account.id,
|
||||
amount: this.modals.addAccount.startingBalance,
|
||||
category: startingBalanceAcc,
|
||||
cleared: true,
|
||||
description: 'Starting Balance',
|
||||
time: new Date(),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
} else if (account.type === 'tracking') {
|
||||
return fetch('/api/transactions', {
|
||||
body: JSON.stringify({
|
||||
account: account.id,
|
||||
amount: this.modals.addAccount.startingBalance,
|
||||
cleared: true,
|
||||
description: 'Starting Balance',
|
||||
time: new Date(),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
} else if (account.type === 'category') {
|
||||
return fetch(`/api/accounts/${startingBalanceAcc}/transfer/${account.id}?amount=${this.modals.addAccount.startingBalance}`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
throw new Error('invalid account type detected')
|
||||
})
|
||||
.then(() => this.$emit('update-accounts'))
|
||||
.then(() => {
|
||||
Modal.getInstance(this.$refs.createAccountModal).toggle()
|
||||
|
||||
this.modals.addAccount = {
|
||||
name: '',
|
||||
startingBalance: 0,
|
||||
type: 'budget',
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
name: 'AccountingAppSidebar',
|
||||
|
||||
props: {
|
||||
accounts: {
|
||||
required: true,
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
46
frontend/components/app.vue
Normal file
46
frontend/components/app.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div class="d-flex h-100">
|
||||
<div
|
||||
class="d-flex flex-column flex-shrink-0 p-3"
|
||||
style="width: 300px"
|
||||
>
|
||||
<accounts-sidebar
|
||||
:accounts="accounts"
|
||||
@updateAccounts="fetchAccounts"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 p-3">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import accountsSidebar from './accountsSidebar.vue'
|
||||
|
||||
export default {
|
||||
components: { accountsSidebar },
|
||||
|
||||
created() {
|
||||
this.fetchAccounts()
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
accounts: [],
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchAccounts() {
|
||||
return fetch('/api/accounts?with-balances')
|
||||
.then(resp => resp.json())
|
||||
.then(data => {
|
||||
this.accounts = data
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
name: 'AccountingAppMain',
|
||||
}
|
||||
</script>
|
26
frontend/lato.scss
Normal file
26
frontend/lato.scss
Normal file
|
@ -0,0 +1,26 @@
|
|||
/* lato-regular - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-regular.woff2') format('woff2');
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
||||
|
||||
/* lato-italic - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-italic.woff2') format('woff2');
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
||||
|
||||
/* lato-700 - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-700.woff2') format('woff2');
|
||||
/* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
94
frontend/latofont/OFL.txt
Executable file
94
frontend/latofont/OFL.txt
Executable file
|
@ -0,0 +1,94 @@
|
|||
Copyright (c) 2010-2015, Łukasz Dziedzic (dziedzic@typoland.com),
|
||||
with Reserved Font Name Lato.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
BIN
frontend/latofont/lato-v20-latin-ext_latin-700.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-700.woff2
Normal file
Binary file not shown.
BIN
frontend/latofont/lato-v20-latin-ext_latin-italic.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-italic.woff2
Normal file
Binary file not shown.
BIN
frontend/latofont/lato-v20-latin-ext_latin-regular.woff2
Normal file
BIN
frontend/latofont/lato-v20-latin-ext_latin-regular.woff2
Normal file
Binary file not shown.
20
frontend/main.js
Normal file
20
frontend/main.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
/* eslint-disable sort-imports */
|
||||
import { createApp, h } from 'vue'
|
||||
|
||||
import './style.scss'
|
||||
|
||||
import appMain from './components/app.vue'
|
||||
import router from './router.js'
|
||||
|
||||
const app = createApp({
|
||||
name: 'AccountingApp',
|
||||
|
||||
render() {
|
||||
return h(appMain)
|
||||
},
|
||||
|
||||
router,
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
13
frontend/router.js
Normal file
13
frontend/router.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{ component: null, name: 'budget', path: '/' },
|
||||
{ component: null, name: 'account-transactions', path: '/accounts/:id' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
10
frontend/style.scss
Normal file
10
frontend/style.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Force local fonts
|
||||
$web-font-path: '';
|
||||
|
||||
@import "../node_modules/bootstrap/dist/css/bootstrap.css";
|
||||
@import "lato";
|
||||
|
||||
$fa-font-path: "../node_modules/@fortawesome/fontawesome-pro/webfonts";
|
||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/fontawesome.scss";
|
||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/solid.scss";
|
||||
@import "../node_modules/@fortawesome/fontawesome-pro/scss/brands.scss";
|
2
main.go
2
main.go
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"git.luzifer.io/luzifer/accounting/pkg/api"
|
||||
"git.luzifer.io/luzifer/accounting/pkg/database"
|
||||
"git.luzifer.io/luzifer/accounting/pkg/frontend"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
@ -60,6 +61,7 @@ func main() {
|
|||
|
||||
router := mux.NewRouter()
|
||||
api.RegisterHandler(router.PathPrefix("/api").Subrouter(), dbc, logrus.StandardLogger())
|
||||
frontend.RegisterHandler(router, logrus.StandardLogger())
|
||||
|
||||
var hdl http.Handler = router
|
||||
hdl = httpHelper.GzipHandler(hdl)
|
||||
|
|
3471
package-lock.json
generated
Normal file
3471
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.23.3",
|
||||
"esbuild": "^0.19.11",
|
||||
"esbuild-plugin-vue3": "^0.4.0",
|
||||
"esbuild-sass-plugin": "^2.16.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.20.1"
|
||||
},
|
||||
"name": "accounting",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-pro": "^6.4.2",
|
||||
"bootstrap": "^5.3.2",
|
||||
"vue": "^3.4.14",
|
||||
"vue-router": "^4.2.5"
|
||||
}
|
||||
}
|
|
@ -45,7 +45,7 @@ func (a apiServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -69,9 +69,12 @@ func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
var payload any
|
||||
var (
|
||||
payload any
|
||||
showHidden = r.URL.Query().Has("with-hidden")
|
||||
)
|
||||
if r.URL.Query().Has("with-balances") {
|
||||
accs, err := a.dbc.ListAccountBalances()
|
||||
accs, err := a.dbc.ListAccountBalances(showHidden)
|
||||
if err != nil {
|
||||
a.errorResponse(w, err, "getting account balances", http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -80,14 +83,14 @@ func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
|||
} else {
|
||||
at := database.AccountType(r.URL.Query().Get("account-type"))
|
||||
if at.IsValid() {
|
||||
accs, err := a.dbc.ListAccountsByType(at)
|
||||
accs, err := a.dbc.ListAccountsByType(at, showHidden)
|
||||
if err != nil {
|
||||
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
payload = accs
|
||||
} else {
|
||||
accs, err := a.dbc.ListAccounts()
|
||||
accs, err := a.dbc.ListAccounts(showHidden)
|
||||
if err != nil {
|
||||
a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -116,7 +119,7 @@ func (a apiServer) handleTransferMoney(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if amount, err = strconv.ParseFloat(mux.Vars(r)["to"], 64); err != nil {
|
||||
if amount, err = strconv.ParseFloat(r.URL.Query().Get("amount"), 64); err != nil {
|
||||
a.errorResponse(w, err, "parsing amount", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ func (a apiServer) handleCreateTransaction(w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a apiServer) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
@ -1,13 +1,41 @@
|
|||
package database
|
||||
|
||||
import "github.com/google/uuid"
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
const constAcctIDNamespace = "17de217e-94d7-4a9b-8833-ecca7f0eb6ca"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const constAcctIDNamespace = "00000000-0000-0000-0000-%012s"
|
||||
|
||||
var (
|
||||
// UnallocatedMoney is a category UUID which is automatically created
|
||||
// during database migration phase and therefore always available
|
||||
UnallocatedMoney = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("unallocated-money"))
|
||||
UnallocatedMoney = makeConstAcctID(1)
|
||||
// StartingBalance is a category UUID which is automatically created
|
||||
// and hidden during database migration and used in frontend as constant
|
||||
StartingBalance = makeConstAcctID(2) //nolint:gomnd
|
||||
|
||||
invalidAcc = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("INVALID ACCOUNT"))
|
||||
invalidAcc = makeConstAcctID(math.MaxUint32)
|
||||
|
||||
migrateCreateAccounts = []Account{
|
||||
{
|
||||
BaseModel: BaseModel{ID: UnallocatedMoney},
|
||||
Hidden: false,
|
||||
Name: "Unallocated Money",
|
||||
Type: AccountTypeCategory,
|
||||
},
|
||||
{
|
||||
BaseModel: BaseModel{ID: StartingBalance},
|
||||
Hidden: true,
|
||||
Name: "Starting Balance",
|
||||
Type: AccountTypeCategory,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func makeConstAcctID(fixedNumber uint32) uuid.UUID {
|
||||
return uuid.MustParse(fmt.Sprintf(constAcctIDNamespace, strconv.FormatUint(uint64(fixedNumber), 16)))
|
||||
}
|
||||
|
|
|
@ -60,15 +60,11 @@ func New(dbtype, dsn string) (*Client, error) {
|
|||
return nil, fmt.Errorf("migrating database schema: %w", err)
|
||||
}
|
||||
|
||||
if err = db.Save(&Account{
|
||||
BaseModel: BaseModel{
|
||||
ID: UnallocatedMoney,
|
||||
},
|
||||
Name: "Unallocated Money",
|
||||
Type: AccountTypeCategory,
|
||||
Hidden: false,
|
||||
}).Error; err != nil {
|
||||
return nil, fmt.Errorf("ensuring unallocated money category: %w", err)
|
||||
for i := range migrateCreateAccounts {
|
||||
a := migrateCreateAccounts[i]
|
||||
if err = db.Save(&a).Error; err != nil {
|
||||
return nil, fmt.Errorf("ensuring default account %q: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
|
@ -146,8 +142,8 @@ func (c *Client) GetTransactionByID(id uuid.UUID) (tx Transaction, err error) {
|
|||
|
||||
// ListAccountBalances returns a list of accounts with their
|
||||
// corresponding balance
|
||||
func (c *Client) ListAccountBalances() (a []AccountBalance, err error) {
|
||||
accs, err := c.ListAccounts()
|
||||
func (c *Client) ListAccountBalances(showHidden bool) (a []AccountBalance, err error) {
|
||||
accs, err := c.ListAccounts(showHidden)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing accounts: %w", err)
|
||||
}
|
||||
|
@ -191,9 +187,17 @@ func (c *Client) ListAccountBalances() (a []AccountBalance, err error) {
|
|||
}
|
||||
|
||||
// ListAccounts returns a list of all accounts
|
||||
func (c *Client) ListAccounts() (a []Account, err error) {
|
||||
//
|
||||
//revive:disable-next-line:flag-parameter
|
||||
func (c *Client) ListAccounts(showHidden bool) (a []Account, err error) {
|
||||
if err = c.retryRead(func(db *gorm.DB) error {
|
||||
return db.Find(&a, "hidden = ?", false).Error
|
||||
q := db.Model(&Account{})
|
||||
|
||||
if !showHidden {
|
||||
q = q.Where("hidden = ?", false)
|
||||
}
|
||||
|
||||
return q.Find(&a).Error
|
||||
}); err != nil {
|
||||
return a, fmt.Errorf("listing accounts: %w", err)
|
||||
}
|
||||
|
@ -202,9 +206,17 @@ func (c *Client) ListAccounts() (a []Account, err error) {
|
|||
}
|
||||
|
||||
// ListAccountsByType returns a list of all accounts of the given type
|
||||
func (c *Client) ListAccountsByType(at AccountType) (a []Account, err error) {
|
||||
//
|
||||
//revive:disable-next-line:flag-parameter
|
||||
func (c *Client) ListAccountsByType(at AccountType, showHidden bool) (a []Account, err error) {
|
||||
if err = c.retryRead(func(db *gorm.DB) error {
|
||||
return db.Find(&a, "type = ?", at).Error
|
||||
q := db.Where("type = ?", at)
|
||||
|
||||
if !showHidden {
|
||||
q = q.Where("hidden = ?", false)
|
||||
}
|
||||
|
||||
return q.Find(&a).Error
|
||||
}); err != nil {
|
||||
return a, fmt.Errorf("listing accounts: %w", err)
|
||||
}
|
||||
|
|
|
@ -43,29 +43,29 @@ func TestAccountManagement(t *testing.T) {
|
|||
assert.Equal(t, "test", act.Name)
|
||||
|
||||
// List all accounts
|
||||
accs, err := dbc.ListAccounts()
|
||||
accs, err := dbc.ListAccounts(false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, accs, 2)
|
||||
|
||||
// Hide account and list again
|
||||
assert.NoError(t, dbc.UpdateAccountHidden(actID, true))
|
||||
accs, err = dbc.ListAccounts()
|
||||
accs, err = dbc.ListAccounts(false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, accs, 1)
|
||||
|
||||
// Unhide account and list again
|
||||
assert.NoError(t, dbc.UpdateAccountHidden(actID, false))
|
||||
accs, err = dbc.ListAccounts()
|
||||
accs, err = dbc.ListAccounts(false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, accs, 2)
|
||||
|
||||
// List accounts from other type
|
||||
accs, err = dbc.ListAccountsByType(AccountTypeCategory)
|
||||
accs, err = dbc.ListAccountsByType(AccountTypeCategory, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, accs, 1)
|
||||
|
||||
// List accounts from existing type
|
||||
accs, err = dbc.ListAccountsByType(AccountTypeBudget)
|
||||
accs, err = dbc.ListAccountsByType(AccountTypeBudget, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, accs, 1)
|
||||
|
||||
|
@ -128,7 +128,7 @@ func TestTransactions(t *testing.T) {
|
|||
assert.NotEqual(t, uuid.Nil, tx.ID)
|
||||
|
||||
// Now we should have money…
|
||||
bals, err := dbc.ListAccountBalances()
|
||||
bals, err := dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 1000)
|
||||
checkAcctBal(bals, tb2.ID, 0)
|
||||
|
@ -138,7 +138,7 @@ func TestTransactions(t *testing.T) {
|
|||
|
||||
// Lets redistribute the money
|
||||
require.NoError(t, dbc.TransferMoney(UnallocatedMoney, tc.ID, 500))
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 1000)
|
||||
checkAcctBal(bals, tb2.ID, 0)
|
||||
|
@ -148,7 +148,7 @@ func TestTransactions(t *testing.T) {
|
|||
|
||||
// Now transfer some money to another budget account
|
||||
require.NoError(t, dbc.TransferMoney(tb1.ID, tb2.ID, 100))
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 900)
|
||||
checkAcctBal(bals, tb2.ID, 100)
|
||||
|
@ -158,7 +158,7 @@ func TestTransactions(t *testing.T) {
|
|||
|
||||
// And some to a tracking account (needs category)
|
||||
require.NoError(t, dbc.TransferMoneyWithCategory(tb1.ID, tt.ID, 100, tc.ID))
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 800)
|
||||
checkAcctBal(bals, tb2.ID, 100)
|
||||
|
@ -178,7 +178,7 @@ func TestTransactions(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, lltx.Cleared)
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 700)
|
||||
checkAcctBal(bals, tb2.ID, 100)
|
||||
|
@ -197,7 +197,7 @@ func TestTransactions(t *testing.T) {
|
|||
|
||||
// Oh, wrong category
|
||||
require.NoError(t, dbc.UpdateTransactionCategory(lltx.ID, UnallocatedMoney))
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 700)
|
||||
checkAcctBal(bals, tb2.ID, 100)
|
||||
|
@ -219,7 +219,7 @@ func TestTransactions(t *testing.T) {
|
|||
|
||||
// We made an error and didn't pay the landlord
|
||||
require.NoError(t, dbc.DeleteTransaction(lltx.ID))
|
||||
bals, err = dbc.ListAccountBalances()
|
||||
bals, err = dbc.ListAccountBalances(false)
|
||||
require.NoError(t, err)
|
||||
checkAcctBal(bals, tb1.ID, 800)
|
||||
checkAcctBal(bals, tb2.ID, 100)
|
||||
|
|
20
pkg/frontend/assets/index.html
Normal file
20
pkg/frontend/assets/index.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html class="h-100" lang="en" data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="/assets/app.css" rel="stylesheet">
|
||||
<title>Accounting</title>
|
||||
<style>
|
||||
[v-cloak] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="h-100">
|
||||
<div id="app" class="h-100" v-cloak></div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
79
pkg/frontend/frontend.go
Normal file
79
pkg/frontend/frontend.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
// Package frontend contains compiled frontend assets and a registration
|
||||
// for the HTTP listeners
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type (
|
||||
frontendServer struct {
|
||||
router *mux.Router
|
||||
log *logrus.Logger
|
||||
}
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
|
||||
// RegisterHandler takes a Router and registers the frontend onto that
|
||||
// router
|
||||
func RegisterHandler(router *mux.Router, logger *logrus.Logger) {
|
||||
srv := frontendServer{router, logger}
|
||||
|
||||
router.
|
||||
PathPrefix("/assets").
|
||||
Handler(http.StripPrefix("/assets", http.HandlerFunc(srv.handleAsset))).
|
||||
Methods(http.MethodGet)
|
||||
router.NotFoundHandler = http.HandlerFunc(srv.handleIndex)
|
||||
}
|
||||
|
||||
func (f frontendServer) handleAsset(w http.ResponseWriter, r *http.Request) {
|
||||
asset, err := assets.Open(path.Join("assets", r.URL.Path))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(w, "that's not the file you're looking for", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("opening asset: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := asset.Close(); err != nil {
|
||||
f.log.WithError(err).Error("closing assets file (leaked fd)")
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
|
||||
|
||||
if _, err = io.Copy(w, asset); err != nil {
|
||||
f.log.WithError(err).Debug("copying index to browser")
|
||||
}
|
||||
}
|
||||
|
||||
func (f frontendServer) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
index, err := assets.Open("assets/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("opening index: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := index.Close(); err != nil {
|
||||
f.log.WithError(err).Error("closing assets file (leaked fd)")
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = io.Copy(w, index); err != nil {
|
||||
f.log.WithError(err).Debug("copying index to browser")
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue