This commit is contained in:
Simon Vieille 2024-09-17 15:00:21 +02:00
commit c24e4ae5a8
38 changed files with 8612 additions and 101 deletions

5
.gitignore vendored
View file

@ -1 +1,6 @@
/_data
/budget-go
/foo
/server
/node_modules
/web/view/static

9
bin/watch.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/sh
while true; do
./node_modules/.bin/webpack
templ generate
screen -S budget -d -m go run ./cmd/server
inotifywait -r . -e close_write
screen -X -S budget quit
done

View file

@ -11,12 +11,13 @@ import (
userDelete "gitnet.fr/deblan/budget/cli/user/delete"
"gitnet.fr/deblan/budget/config"
"gitnet.fr/deblan/budget/database/manager"
"gitnet.fr/deblan/budget/database/model"
)
func main() {
ini := flag.String("c", "config.ini", "Path to config.ini")
config.Get().Load(*ini)
manager.Get().AutoMigrate()
manager.Get().Db.AutoMigrate(&model.User{})
app := &cli.App{
Commands: []*cli.Command{

View file

@ -8,6 +8,8 @@ import (
"net/http"
"text/template"
rice "github.com/GeertJohan/go.rice"
"github.com/go-playground/validator"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
@ -21,6 +23,14 @@ type TemplateRenderer struct {
templates *template.Template
}
type AppValidator struct {
validator *validator.Validate
}
func (cv *AppValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
func main() {
ini := flag.String("c", "config.ini", "Path to config.ini")
conf := config.Get()
@ -29,6 +39,12 @@ func main() {
e := echo.New()
e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))
e.Validator = &AppValidator{validator: validator.New()}
e.Static("/static", "static")
assetHandler := http.FileServer(rice.MustFindBox("../../web/view/static").HTTPBox())
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler)))
router.RegisterControllers(e)
if err := e.Start(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) {

View file

@ -1,12 +1,13 @@
[server]
port = 1324
address = "127.0.0.1"
address = "0.0.0.0"
[security]
secret = "e93865c991358ff7a14f9781fa33ba4f28c33bb8d1cf3490ce6fd68521513536"
[log]
level = "debug"
;level = "warn"
[database]
dsn = "root:root@tcp(127.0.0.1:3306)/budget"
dsn = "root:root@tcp(127.0.0.1:3306)/budget?parseTime=true"

View file

@ -10,12 +10,12 @@ import (
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"unique"`
Password string
DisplayName string
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
Password string `json:"-"`
DisplayName string `json:"display_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func NewUser(username, password, displayName string) *User {
@ -30,7 +30,7 @@ func NewUser(username, password, displayName string) *User {
}
func (u *User) UpdatePassword(password string) {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 1000)
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
u.Password = string(hashedPassword)
}

28
frontend/js/App.vue Normal file
View file

@ -0,0 +1,28 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import { BNavItem } from 'bootstrap-vue-next'
</script>
<template>
<main class="d-flex flex-nowrap">
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark" id="nav">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<span class="fs-4"><i class="fa-solid fa-coins"></i> Budget</span>
</a>
<hr>
<ul class="nav nav-pills flex-column mb-auto">
<RouterLink to="/" custom v-slot="{ href, route, navigate, isActive, isExactActive }">
<BNavItem :href="href" :active="isActive">{{ route.name }}</BNavItem>
</RouterLink>
<RouterLink to="/users" custom v-slot="{ href, route, navigate, isActive, isExactActive }">
<BNavItem :href="href" :active="isActive">{{ route.name }}</BNavItem>
</RouterLink>
</ul>
</div>
<RouterView id="body" />
</main>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,24 @@
<template>
<span>
{{ props.label }}
<i v-if="isAsc()" class="ms-1 fa-solid fa-sort-up"></i>
<i v-if="isDesc()" class="ms-1 fa-solid fa-sort-down"></i>
</span>
</template>
<script setup>
const props = defineProps(['currentOrder', 'currentSort', 'order', 'label'])
const isActive = () => {
return props.currentOrder === props.order
}
const isAsc = () => {
return isActive() && props.currentSort === 'asc'
}
const isDesc = () => {
return isActive() && props.currentSort === 'desc'
}
</script>

17
frontend/js/main.js Normal file
View file

@ -0,0 +1,17 @@
import '../scss/main.scss'
import {
createApp
} from 'vue'
import App from './App.vue'
import router from './router'
import {
createBootstrap
} from 'bootstrap-vue-next'
const app = createApp(App)
app.use(createBootstrap())
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,19 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'Transactions',
component: () => import('../views/TransactionsView.vue')
},
{
path: '/users',
name: 'Utilisateurs',
component: () => import('../views/UsersView.vue')
},
]
})
export default router

View file

@ -0,0 +1,168 @@
<template>
<BContainer fluid>
<BTable
v-model:sort-by="sortBy"
:sort-internal="true"
:items="itemsTyped"
:fields="fieldsTyped"
:current-page="currentPage"
:per-page="perPage"
:filter="filter"
:responsive="false"
:filterable="filterOn"
:small="true"
:multisort="true"
@filtered="onFiltered"
>
<template #cell(name)="row">
{{ (row.value).first }}
{{ (row.value).last }}
</template>
<template #cell(actions)="row">
<BButton size="sm" class="mr-1" @click="info(row.item, row.index)"> Info modal </BButton>
<BButton size="sm" @click="row.toggleDetails">
{{ row.detailsShowing ? 'Hide' : 'Show' }} Details
</BButton>
</template>
<template #row-details="row">
<BCard>
<ul>
<li v-for="(value, key) in row.item" :key="key">{{ key }}: {{ value }}</li>
<BButton size="sm" @click="row.toggleDetails"> Toggle Details </BButton>
</ul>
</BCard>
</template>
</BTable>
<BModal
:id="infoModal.id"
v-model="infoModal.open"
:title="infoModal.title"
:ok-only="true"
@hide="resetInfoModal"
>
<pre>{{ infoModal.content }}</pre>
</BModal>
</BContainer>
</template>
<script setup>
import {
BButton,
BFormSelect,
BInputGroup,
BFormCheckbox,
BFormGroup,
BCol,
BFormInput,
BInputGroupText,
BFormCHeckbox,
BPagination,
BRow,
BModal,
BContainer,
BTable,
BTableSortBy
} from 'bootstrap-vue-next'
import {computed, reactive, ref} from 'vue'
const itemsTyped = [
{isActive: true, age: 40, name: {first: 'Dickerson', last: 'Macdonald'}},
{isActive: false, age: 21, name: {first: 'Larsen', last: 'Shaw'}},
{
isActive: false,
age: 9,
name: {first: 'Mini', last: 'Navarro'},
_rowVariant: 'success',
},
{isActive: false, age: 89, name: {first: 'Geneva', last: 'Wilson'}},
{isActive: true, age: 38, name: {first: 'Jami', last: 'Carney'}},
{isActive: false, age: 27, name: {first: 'Essie', last: 'Dunlap'}},
{isActive: true, age: 40, name: {first: 'Thor', last: 'Macdonald'}},
{
isActive: true,
age: 87,
name: {first: 'Larsen', last: 'Shaw'},
_cellVariants: {age: 'danger', isActive: 'warning'},
},
{isActive: false, age: 26, name: {first: 'Mitzi', last: 'Navarro'}},
{isActive: false, age: 22, name: {first: 'Genevieve', last: 'Wilson'}},
{isActive: true, age: 38, name: {first: 'John', last: 'Carney'}},
{isActive: false, age: 29, name: {first: 'Dick', last: 'Dunlap'}},
]
const fieldsTyped = [
{
key: 'name',
label: 'Person full name',
sortable: true,
sortDirection: 'desc',
},
{
key: 'sortableName',
label: 'Person sortable name',
sortable: true,
sortDirection: 'desc',
formatter: (_value, _key, item) =>
item ? `${item.name.last}, ${item.name.first}` : 'Something went wrong',
sortByFormatted: true,
filterByFormatted: true,
},
{key: 'age', label: 'Person age', sortable: true, class: 'text-center'},
{
key: 'isActive',
label: 'Is Active',
formatter: (value) => (value ? 'Yes' : 'No'),
sortable: true,
sortByFormatted: true,
filterByFormatted: true,
},
{key: 'actions', label: 'Actions'},
]
const pageOptions = [
{value: 5, text: '5'},
{value: 10, text: '10'},
{value: 15, text: '15'},
{value: 100, text: 'Show a lot'},
]
const totalRows = ref(itemsTyped.length)
const currentPage = ref(1)
const perPage = ref(5)
const sortBy = ref([])
const sortDirection = ref('asc')
const filter = ref('')
const filterOn = ref([])
const infoModal = reactive({
open: false,
id: 'info-modal',
title: '',
content: '',
})
// Create an options list from our fields
const sortOptions = computed(() =>
fieldsTyped.filter((f) => f.sortable).map((f) => ({text: f.label, value: f.key}))
)
function info(item, index) {
infoModal.title = `Row index: ${index}`
infoModal.content = JSON.stringify(item, null, 2)
infoModal.open = true
}
function resetInfoModal() {
infoModal.title = ''
infoModal.content = ''
}
function onFiltered(filteredItems) {
// Trigger pagination to update the number of buttons/pages due to filtering
totalRows.value = filteredItems.length
currentPage.value = 1
}
function onAddSort() {
sortBy.value.push({key: '', order: 'asc'})
}
</script>

View file

@ -0,0 +1,160 @@
<template>
<BContainer fluid class="p-0">
<BTableSimple caption-top responsive v-if="data !== null">
<BThead>
<BTr>
<BTh v-for="field in fields" :width="field.width" class="cursor" :class="field.classes" @click="doSort(field.key)">
<SortButton :currentOrder="order" :currentSort="sort" :order="field.key" :label="field.label" />
</BTh>
</Btr>
</BThead>
<BTbody>
<BTr v-for="row in data.rows">
<BTd v-for="field in fields" @click="doEdit(row)" class="cursor">
{{ row[field.key] }}
</BTd>
</Btr>
</BTbody>
</BTableSimple>
<BModal v-if="form !== null" v-model="formShow" :title="form?.label" @ok="doSave">
<BAlert :model-value="form.error !== null" variant="danger" v-text="form.error"></BAlert>
<BForm @submit="doSave">
<BFormGroup
class="mb-2"
v-for="(field, key) in form.fields"
:id="'form-label-' + key"
:label="field.label"
:label-for="'form-label-' + key"
:description="field.description"
>
<BFormInput
:id="'form-input-' + key"
v-model="form.data[field.key]"
:type="form.type"
:required="form.required"
/>
</BFormGroup>
</BForm>
</BModal>
</BContainer>
</template>
<script setup>
import {
BTbody,
BThead,
BTr,
BTd,
BTh,
BRow,
BContainer,
BTableSimple,
BModal,
BButton,
BForm,
BFormGroup,
BFormInput,
BAlert,
} from 'bootstrap-vue-next'
import SortButton from './../components/SortButton.vue'
import {ref, onMounted, reactive} from 'vue'
const data = ref(null)
const order = ref(null)
const sort = ref(null)
const page = ref(null)
const pages = ref(null)
const limit = ref(null)
const form = ref(null)
const formShow = ref(false)
const refresh = (query) => {
fetch(`/api/user?${new URLSearchParams(query)}`)
.then(function(response) {
return response.json()
})
.then(function(value) {
data.value = value
order.value = value.order
sort.value = value.sort
page.value = value.page
pages.value = value.total_pages
limit.value = value.limit
})
}
const doEdit = (item) => {
form.value = {
action: `api/user/?${item.id}`,
method: 'POST',
data: item,
label: item.display_name,
error: null,
fields: [
{
label: 'Nom',
type: 'text',
required: true,
key: 'display_name',
},
{
label: 'Nom d\'utilisateur',
type: 'text',
required: true,
key: 'username',
},
{
label: 'Mot de passe',
type: 'password',
required: false,
key: 'password',
},
]
}
formShow.value = true
}
const doSave = (e) => {
console.log(form.value)
}
const doSort = (key) => {
let nextSort = 'asc'
if (order.value === key) {
nextSort = (sort.value === 'asc' ? 'desc' : 'asc')
}
refresh({
order: key,
sort: nextSort,
limit: limit,
page: 1,
})
sort.value = key
}
const fields = [
{
key: 'id',
label: 'ID',
width: '70px',
},
{
key: 'display_name',
label: 'Nom',
width: '30%',
},
{
key: 'username',
label: 'Utilisateur',
},
]
onMounted(() => {
refresh({})
})
</script>

24
frontend/scss/main.scss Normal file
View file

@ -0,0 +1,24 @@
@import "~bootstrap/scss/bootstrap";
@import "~bootstrap-vue-next/dist/bootstrap-vue-next.css";
@import "~@fortawesome/fontawesome-free/css/all.css";
.gradient-custom {
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
}
.cursor {
cursor: pointer;
}
#app, main {
min-height: 100vh;
max-height: 100vh;
}
#nav {
width: 230px;
}
#body {
width: calc(100vw - 230px);
}

8
go.mod
View file

@ -13,9 +13,16 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/GeertJohan/go.rice v1.0.3 // indirect
github.com/a-h/templ v0.2.778 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/daaku/go.zipexe v1.0.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator v9.31.0+incompatible // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/context v1.1.2 // indirect
@ -26,6 +33,7 @@ require (
github.com/labstack/echo-contrib v0.17.1 // indirect
github.com/labstack/echo/v4 v4.12.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect

21
go.sum
View file

@ -1,7 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.3 h1:k5viR+xGtIhF61125vCE1cmJ5957RQGXG6dmbaWZSmI=
github.com/GeertJohan/go.rice v1.0.3/go.mod h1:XVdrU4pW00M4ikZed5q56tPf1v2KwnIKeIdc9CBYNt4=
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
@ -10,9 +14,21 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWs
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/daaku/go.zipexe v1.0.2 h1:Zg55YLYTr7M9wjKn8SY/WcpuuEi+kR2u4E8RhvpyXmk=
github.com/daaku/go.zipexe v1.0.2/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA=
github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@ -24,6 +40,7 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -34,6 +51,8 @@ github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+k
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -41,6 +60,7 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@ -58,6 +78,7 @@ github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=

7527
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0",
"@symfony/webpack-encore": "github:symfony/webpack-encore",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.24.16",
"raw-loader": "^4.0.2",
"vue": "^3.4.29",
"vue-template-compiler": "^2.7.16"
},
"devDependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@vitejs/plugin-vue": "^5.0.5",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.1",
"postcss-loader": "^8.1.1",
"sass": "^1.78.0",
"sass-loader": "^16.0.1",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
"vue-loader": "^17.4.2",
"vue-router": "^4.4.5",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-notifier": "^1.15.0"
}
}

View file

@ -1,5 +0,0 @@
<!doctype html><html>
<body><section class=\"vh-100 gradient-custom\"><div class=\"container py-5 h-100\"><div class=\"row d-flex justify-content-center align-items-center h-100\"><div class=\"col-12 col-md-8 col-lg-6 col-xl-5\"><div class=\"card bg-dark text-white\" style=\"border-radius: 1rem;\"><div class=\"card-body p-5\"><div class=\"mb-md-5 mt-md-4 pb-5\"><form action=\"/login\" method=\"POST\">
<div class=\"alert alert-danger\">Mauvais identifiants.</div>
<div class=\"form-outline form-white mb-4\"><label class=\"form-label\" for=\"username\">Nom d'utilisateur</label> <input type=\"text\" name=\"username\" id=\"username\" class=\"form-control form-control-lg\"></div><div class=\"form-outline form-white mb-4\"><label class=\"form-label\" for=\"password\">Mot de passe</label> <input type=\"password\" name=\"password\" id=\"password\" class=\"form-control form-control-lg\"></div><div class=\"d-grid gap-2\"><input class=\"btn btn-lg btn-primary\" type=\"submit\" value=\"Login\"></div></form></div></div></div></div></div></div></section>
</body></html>

View file

@ -1,20 +0,0 @@
package template
templ Head(title string) {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{ title }</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<style>
.gradient-custom {
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
}
templ JS() {
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="main.js"></script>
}

View file

@ -1,3 +0,0 @@
<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>
</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN\" crossorigin=\"anonymous\"><style>\n\t\t\t.gradient-custom {\n\t\t\t\tbackground: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))\n\t\t\t}\n\t\t</style></head>
<script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\" integrity=\"sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL\" crossorigin=\"anonymous\"></script><script src=\"main.js\"></script>

View file

@ -1,15 +0,0 @@
package home
import "gitnet.fr/deblan/budget/view/template"
templ Page() {
<!doctype html>
<html>
@template.Head("Login")
<body>
Home!
@template.JS()
</body>
</html>
}

View file

@ -1,3 +0,0 @@
<!doctype html><html>
<body>Home!
</body></html>

View file

@ -1,17 +0,0 @@
package view
import (
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
)
func Render(ctx echo.Context, statusCode int, t templ.Component) error {
buf := templ.GetBuffer()
defer templ.ReleaseBuffer(buf)
if err := t.Render(ctx.Request().Context(), buf); err != nil {
return err
}
return ctx.HTML(statusCode, buf.String())
}

View file

@ -1,10 +1,10 @@
package home
package app
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/database/model"
"gitnet.fr/deblan/budget/view"
"gitnet.fr/deblan/budget/view/template/home"
"gitnet.fr/deblan/budget/web/view"
"gitnet.fr/deblan/budget/web/view/template/app"
)
type Controller struct {
@ -23,5 +23,5 @@ func (ctrl *Controller) HomeGet(c echo.Context) error {
return c.Redirect(302, "/login")
}
return view.Render(c, 200, home.Page())
return view.Render(c, 200, app.Page())
}

View file

@ -8,8 +8,8 @@ import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/database/manager"
"gitnet.fr/deblan/budget/database/model"
"gitnet.fr/deblan/budget/view"
"gitnet.fr/deblan/budget/view/template/auth"
"gitnet.fr/deblan/budget/web/view"
"gitnet.fr/deblan/budget/web/view/template/auth"
)
type Controller struct {

View file

@ -0,0 +1,30 @@
package crud
type Configuration struct {
Model interface{}
Models any
ValidOrders []string
ValidLimits []int
DefaultLimit int
CreateModel func() interface{}
}
func (c *Configuration) IsValidOrder(value string) bool {
for _, v := range c.ValidOrders {
if v == value {
return true
}
}
return false
}
func (c *Configuration) IsValidLimit(value int) bool {
for _, v := range c.ValidLimits {
if v == value {
return true
}
}
return false
}

159
web/controller/crud/crud.go Normal file
View file

@ -0,0 +1,159 @@
package crud
import (
"math"
"strconv"
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/database/manager"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UpdateCallback func(*gorm.DB, interface{}, interface{}) (interface{}, error)
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
}
type Controller struct {
Config Configuration
}
func New() *Controller {
c := Controller{}
return &c
}
func (ctrl *Controller) With(config Configuration) *Controller {
ctrl.Config = config
return ctrl
}
func (ctrl *Controller) List(c echo.Context) error {
db := manager.Get().Db
db = db.Model(ctrl.Config.Model)
order := c.QueryParam("order")
sort := c.QueryParam("sort")
if !ctrl.Config.IsValidOrder(order) {
order = "id"
sort = "asc"
}
db.Order(clause.OrderByColumn{
Column: clause.Column{Name: order},
Desc: sort == "desc",
})
data := ListData{
Limit: ctrl.Config.DefaultLimit,
Order: order,
Sort: sort,
}
limit, err := strconv.Atoi(c.QueryParam("limit"))
if err == nil && ctrl.Config.IsValidLimit(limit) {
data.Limit = limit
}
page, err := strconv.Atoi(c.QueryParam("page"))
if err == nil && page > 0 {
data.Page = page
}
ctrl.Paginate(&data, db)
return c.JSON(200, data)
}
func (ctrl *Controller) Show(c echo.Context) error {
db := manager.Get().Db
value, err := strconv.Atoi(c.Param("id"))
if err != nil {
return err
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
Code: 404,
Message: "Not found",
})
}
item := ctrl.Config.CreateModel()
db.Model(ctrl.Config.Model).Where("id = ?", value).First(&item)
return c.JSON(200, item)
}
func (ctrl *Controller) Update(c echo.Context, body interface{}, updateCallback UpdateCallback) error {
db := manager.Get().Db
value, err := strconv.Atoi(c.Param("id"))
if err != nil {
return err
}
var count int64
db.Model(ctrl.Config.Model).Where("id = ?", value).Count(&count)
if count == 0 {
return c.JSON(404, Error{
Code: 404,
Message: "Not found",
})
}
item := ctrl.Config.CreateModel()
db.Model(ctrl.Config.Model).Where("id = ?", value).First(&item)
if err := c.Bind(body); err != nil {
return c.JSON(400, Error{
Code: 400,
Message: "Bad request",
})
}
if err := c.Validate(body); err != nil {
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
})
}
result, err := updateCallback(db, item, body)
if err != nil {
return c.JSON(400, Error{
Code: 400,
Message: err.Error(),
})
}
return c.JSON(200, result)
}
func (ctrl *Controller) Paginate(data *ListData, db *gorm.DB) {
var totalRows int64
db.Model(ctrl.Config.Model).Count(&totalRows)
data.TotalRows = totalRows
totalPages := int(math.Ceil(float64(totalRows) / float64(data.Limit)))
data.TotalPages = totalPages
db.Offset(data.GetOffset())
db.Limit(data.GetLimit())
db.Find(&ctrl.Config.Models)
data.Rows = ctrl.Config.Models
}

View file

@ -0,0 +1,30 @@
package crud
type ListData struct {
Limit int `json:"limit,omitempty;query:limit"`
Page int `json:"page,omitempty;query:page"`
TotalRows int64 `json:"total_rows"`
TotalPages int `json:"total_pages"`
Rows interface{} `json:"rows"`
Order string `json:"order"`
Sort string `json:"sort"`
}
func (p *ListData) GetOffset() int {
return (p.GetPage() - 1) * p.GetLimit()
}
func (p *ListData) GetLimit() int {
if p.Limit == 0 {
p.Limit = 10
}
return p.Limit
}
func (p *ListData) GetPage() int {
if p.Page == 0 {
p.Page = 1
}
return p.Page
}

View file

@ -0,0 +1,78 @@
package user
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/database/model"
"gitnet.fr/deblan/budget/web/controller/crud"
"gorm.io/gorm"
)
type Controller struct {
crud *crud.Controller
}
func (ctrl *Controller) Config() crud.Configuration {
return crud.Configuration{
Model: model.User{},
Models: []model.User{},
ValidOrders: []string{"id", "display_name", "username"},
ValidLimits: []int{20, 50, 100},
DefaultLimit: 20,
CreateModel: func() interface{} {
return new(model.User)
},
}
}
func New(e *echo.Echo) *Controller {
c := Controller{
crud: crud.New(),
}
e.GET("/api/user", c.List)
e.GET("/api/user/:id", c.Show)
e.POST("/api/user/:id", c.Update)
return &c
}
func (ctrl *Controller) List(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).List(c)
}
func (ctrl *Controller) Show(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
return ctrl.crud.With(ctrl.Config()).Show(c)
}
func (ctrl *Controller) Update(c echo.Context) error {
if nil == model.LoadSessionUser(c) {
return c.Redirect(302, "/login")
}
type body struct {
DisplayName string `json:"display_name" form:"display_name" validate:"required"`
Password string `json:"password" form:"password"`
}
return ctrl.crud.With(ctrl.Config()).Update(c, new(body), func(db *gorm.DB, a, b interface{}) (interface{}, error) {
item := a.(*model.User)
update := b.(*body)
item.DisplayName = update.DisplayName
if update.Password != "" {
item.UpdatePassword(update.Password)
}
db.Model(ctrl.crud.Config.Model).Where("id = ?", item.ID).Save(&item)
return item, nil
})
}

View file

@ -2,11 +2,13 @@ package router
import (
"github.com/labstack/echo/v4"
"gitnet.fr/deblan/budget/web/controller/app"
"gitnet.fr/deblan/budget/web/controller/auth"
"gitnet.fr/deblan/budget/web/controller/home"
"gitnet.fr/deblan/budget/web/controller/user"
)
func RegisterControllers(e *echo.Echo) {
auth.New(e)
home.New(e)
app.New(e)
user.New(e)
}

View file

@ -0,0 +1,15 @@
package app
import "gitnet.fr/deblan/budget/web/view/template"
templ Page() {
<!doctype html>
<html>
@template.Head("Budget")
<body>
<div id="app"></div>
@template.JS()
</body>
</html>
}

View file

@ -1,18 +1,21 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.747
package home
// templ: version: v0.2.778
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "gitnet.fr/deblan/budget/view/template"
import "gitnet.fr/deblan/budget/web/view/template"
func Page() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
@ -28,15 +31,15 @@ func Page() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = template.Head("Login").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = template.Head("Budget").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body><div id=\"app\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -44,10 +47,12 @@ func Page() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -1,6 +1,6 @@
package auth
import "gitnet.fr/deblan/budget/view/template"
import "gitnet.fr/deblan/budget/web/view/template"
templ Page(hasError bool) {
<!doctype html>

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.747
// templ: version: v0.2.778
package auth
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -8,11 +8,14 @@ package auth
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "gitnet.fr/deblan/budget/view/template"
import "gitnet.fr/deblan/budget/web/view/template"
func Page(hasError bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
@ -28,7 +31,7 @@ func Page(hasError bool) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -36,17 +39,17 @@ func Page(hasError bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body><section class=\"vh-100 gradient-custom\"><div class=\"container py-5 h-100\"><div class=\"row d-flex justify-content-center align-items-center h-100\"><div class=\"col-12 col-md-8 col-lg-6 col-xl-5\"><div class=\"card bg-dark text-white\" style=\"border-radius: 1rem;\"><div class=\"card-body p-5\"><div class=\"mb-md-5 mt-md-4 pb-5\"><form action=\"/login\" method=\"POST\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasError {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"alert alert-danger\">Mauvais identifiants.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"form-outline form-white mb-4\"><label class=\"form-label\" for=\"username\">Nom d'utilisateur</label> <input type=\"text\" name=\"username\" id=\"username\" class=\"form-control form-control-lg\"></div><div class=\"form-outline form-white mb-4\"><label class=\"form-label\" for=\"password\">Mot de passe</label> <input type=\"password\" name=\"password\" id=\"password\" class=\"form-control form-control-lg\"></div><div class=\"d-grid gap-2\"><input class=\"btn btn-lg btn-primary\" type=\"submit\" value=\"Login\"></div></form></div></div></div></div></div></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -54,10 +57,12 @@ func Page(hasError bool) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,16 @@
package template
import "gitnet.fr/deblan/budget/web/view"
templ Head(title string) {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{ title }</title>
@templ.Raw(view.EntrypointCss("main"))
</head>
}
templ JS() {
@templ.Raw(view.EntrypointJs("main"))
}

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.747
// templ: version: v0.2.778
package template
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -8,9 +8,14 @@ package template
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "gitnet.fr/deblan/budget/web/view"
func Head(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
@ -26,20 +31,28 @@ func Head(title string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/template/base.templ`, Line: 7, Col: 16}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/view/template/base.templ`, Line: 9, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(view.EntrypointCss("main")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -50,6 +63,9 @@ func Head(title string) templ.Component {
func JS() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
@ -65,10 +81,12 @@ func JS() templ.Component {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
templ_7745c5c3_Err = templ.Raw(view.EntrypointJs("main")).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

87
web/view/view.go Normal file
View file

@ -0,0 +1,87 @@
package view
import (
"embed"
"encoding/json"
"fmt"
"strings"
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
)
var (
//go:embed static/*
statics embed.FS
manifest map[string]string
entrypoints map[string]map[string]map[string][]string
)
func Render(ctx echo.Context, statusCode int, t templ.Component) error {
buf := templ.GetBuffer()
defer templ.ReleaseBuffer(buf)
if err := t.Render(ctx.Request().Context(), buf); err != nil {
return err
}
return ctx.HTML(statusCode, buf.String())
}
func Asset(name string) string {
if manifest == nil {
value, _ := statics.ReadFile("static/manifest.json")
json.Unmarshal(value, &manifest)
}
path, ok := manifest[name]
if !ok {
return ""
}
return path
}
func entrypointFiles(app, category string) []string {
if entrypoints == nil {
value, _ := statics.ReadFile("static/entrypoints.json")
json.Unmarshal(value, &entrypoints)
}
entry, ok := entrypoints["entrypoints"][app]
if !ok {
return []string{}
}
files, ok := entry[category]
if !ok {
return []string{}
}
return files
}
func EntrypointJs(app string) string {
files := entrypointFiles(app, "js")
results := []string{}
for _, file := range files {
results = append(results, fmt.Sprintf(`<script src="%s"></script>`, file))
}
return strings.Join(results, "\n")
}
func EntrypointCss(app string) string {
files := entrypointFiles(app, "css")
results := []string{}
for _, file := range files {
results = append(results, fmt.Sprintf(`<link rel="stylesheet" href="%s" />`, file))
}
return strings.Join(results, "\n")
}

73
webpack.config.js Normal file
View file

@ -0,0 +1,73 @@
const Encore = require('@symfony/webpack-encore');
// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
// directory where compiled assets will be stored
.setOutputPath('web/view/static')
// public path used by the web server to access the output path
.setPublicPath('/static')
// only needed for CDN's or subdirectory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
*
* Each entry will result in one JavaScript file (e.g. app.js)
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('main', './frontend/js/main.js')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// will require an extra script tag for runtime.js
// but, you probably want this, unless you're building a single-page app
.enableSingleRuntimeChunk()
/*
* FEATURE CONFIG
*
* Enable & configure other features below. For a full
* list of features, see:
* https://symfony.com/doc/current/frontend.html#adding-more-features
*/
.cleanupOutputBeforeBuild()
// .enableBuildNotifications()
.enableVueLoader(() => {}, {
})
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
.configureBabel((config) => {
config.plugins.push('@babel/plugin-syntax-dynamic-import');
})
// .copyFiles({
// from: './frontend/images',
// to: 'images/[path][name].[hash:8].[ext]'
// })
// enables Sass/SCSS support
.enableSassLoader()
// uncomment if you use TypeScript
//.enableTypeScriptLoader()
// uncomment if you use React
//.enableReactPreset()
// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
//.enableIntegrityHashes(Encore.isProduction())
// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
;
module.exports = Encore.getWebpackConfig();