Update. Add svelte admin frontend

This commit is contained in:
Jan Eitzinger 2025-06-12 20:38:12 +02:00
parent 17ab7c4929
commit 562186fa47
Signed by: moebiusband
GPG Key ID: 2574BA29B90D6DD5
16 changed files with 463 additions and 165 deletions

View File

@ -4,16 +4,16 @@ tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
bin = "tmp/server"
cmd = "make"
delay = 1000
exclude_dir = [
"assets",
"tmp",
"vendor",
"testdata",
"web/frontend/dist",
"web/frontend/node_modules",
"web/static",
]
exclude_file = []
exclude_regex = ["_test.go"]

1
.env Normal file
View File

@ -0,0 +1 @@
SESSION_KEY="67d829bf61dc5f87a73fd814e2c9f629"

25
.gitignore vendored
View File

@ -23,3 +23,28 @@ go.work
tmp
web/static/css/.DS_Store
#
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -5,7 +5,7 @@ SVELTE_COMPONENTS = status
SVELTE_TARGETS = $(addprefix $(FRONTEND)/public/build/,$(addsuffix .ts, $(SVELTE_COMPONENTS)))
.PHONY: $(TARGET)
.PHONY: $(TARGET) clean
.NOTPARALLEL:
$(TARGET): $(SVELTE_TARGETS)
@ -15,3 +15,6 @@ $(TARGET): $(SVELTE_TARGETS)
$(SVELTE_TARGETS): $(SVELTE_SRC)
$(info ===> BUILD frontend)
cd $(FRONTEND) && npm install && npm run build
clean:
@rm -rf tmp

100
internal/api/news.go Normal file
View File

@ -0,0 +1,100 @@
package api
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
)
func createNewsItem(rw http.ResponseWriter, r *http.Request) {
req := repository.CreateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.CreateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func readNewsItems(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
items, err := repo.ListNews(r.Context())
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
slog.Debug("/api/news returned", "newscount", len(items))
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(items); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func readNewsItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
item, err := repo.GetNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(item); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func updateNewsItem(rw http.ResponseWriter, r *http.Request) {
req := repository.UpdateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.UpdateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func deleteNewsItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
err = repo.DeleteNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}

View File

@ -1,15 +1,10 @@
package api
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
)
type ErrorResponse struct {
@ -24,6 +19,12 @@ func MountApiEndpoints(r *http.ServeMux) {
r.HandleFunc("GET /api/news/{id}", readNewsItem)
r.HandleFunc("PATCH /api/news/", updateNewsItem)
r.HandleFunc("DELETE /api/news/{id}", deleteNewsItem)
r.HandleFunc("POST /api/retailers/", createRetailerItem)
r.HandleFunc("GET /api/retailers/", readRetailerItems)
r.HandleFunc("GET /api/retailers/{id}", readRetailerItem)
r.HandleFunc("PATCH /api/retailers/", updateRetailerItem)
r.HandleFunc("DELETE /api/retailers/{id}", deleteRetailerItem)
}
func handleError(err error, statusCode int, rw http.ResponseWriter) {
@ -41,91 +42,3 @@ func decode(r io.Reader, val any) error {
dec.DisallowUnknownFields()
return dec.Decode(val)
}
func createNewsItem(rw http.ResponseWriter, r *http.Request) {
req := repository.CreateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.CreateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func readNewsItems(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
items, err := repo.ListNews(r.Context())
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
slog.Debug("/api/news returned", "newscount", len(items))
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(items); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func readNewsItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
item, err := repo.GetNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(item); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func updateNewsItem(rw http.ResponseWriter, r *http.Request) {
req := repository.UpdateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.UpdateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func deleteNewsItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
err = repo.DeleteNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}

100
internal/api/retailer.go Normal file
View File

@ -0,0 +1,100 @@
package api
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
)
func createRetailerItem(rw http.ResponseWriter, r *http.Request) {
req := repository.CreateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.CreateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func readRetailerItems(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
items, err := repo.ListRetailers(r.Context())
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
slog.Debug("/api/retailers returned", "count", len(items))
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(items); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func readRetailerItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
item, err := repo.GetNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
rw.Header().Add("Content-Type", "application/json")
bw := bufio.NewWriter(rw)
defer bw.Flush()
if err := json.NewEncoder(bw).Encode(item); err != nil {
handleError(err, http.StatusInternalServerError, rw)
return
}
}
func updateRetailerItem(rw http.ResponseWriter, r *http.Request) {
req := repository.UpdateNewsEntryParams{}
if err := decode(r.Body, &req); err != nil {
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
return
}
repo := repository.GetRepository()
err := repo.UpdateNewsEntry(r.Context(), req)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}
func deleteRetailerItem(rw http.ResponseWriter, r *http.Request) {
repo := repository.GetRepository()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
err = repo.DeleteNewsEntry(r.Context(), id)
if err != nil {
handleError(err, http.StatusBadRequest, rw)
return
}
}

View File

@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"html/template"
"log/slog"
"net/http"
@ -13,11 +14,12 @@ func AdminHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tpl := template.Must(template.ParseFS(web.Templates, "templates/admin.html", "templates/base.html"))
viteFragment, err := vite.HTMLFragment(vite.Config{
FS: web.StaticAssets,
FS: web.DistFS(),
IsDev: false,
})
if err != nil {
http.Error(w, "Error instantiating vite fragment", http.StatusInternalServerError)
msg := fmt.Errorf("error instantiating vite fragment: %v", err)
http.Error(w, msg.Error(), http.StatusInternalServerError)
return
}

11
main.go
View File

@ -11,12 +11,14 @@ import (
"strings"
"time"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/api"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/auth"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/handlers"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/middleware"
"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
"git.clustercockpit.org/moebiusband/go-http-skeleton/web"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
@ -116,8 +118,14 @@ func main() {
ctx := context.Background()
q := repository.GetRepository()
hash, err := bcrypt.GenerateFromPassword([]byte(parts[1]), bcrypt.DefaultCost)
password := string(hash)
if err != nil {
slog.Error("Error while encrypting new user password")
os.Exit(1)
}
if err := q.CreateUser(ctx, repository.CreateUserParams{
UserName: parts[0], UserPass: &parts[1],
UserName: parts[0], UserPass: &password,
}); err != nil {
slog.Error("Add User: Could not add new user authentication", "username", parts[0], "error", err.Error())
os.Exit(1)
@ -215,6 +223,7 @@ func main() {
})
})))
api.MountApiEndpoints(mux)
mux.HandleFunc("GET /", handlers.RootHandler())
securedMux := http.NewServeMux()

View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,45 +1,139 @@
<script lang="ts">
import svelteLogo from "./assets/svelte.svg";
import Counter from "./lib/Counter.svelte";
import Table from "./lib/Table.svelte";
import GenericForm from "./lib/GenericForm.svelte";
type Retailer = {
id: number;
shopname: string;
url: string;
country: string;
display: number;
};
let records: Retailer[] = $state([]);
let columns: string[] = ["shopname", "country", "url"];
let activeTab = $state("invoice");
async function fetchData(endpoint: string) {
const res = await fetch("http://localhost:8080/api/" + endpoint);
records = await res.json();
}
$effect(() => {
if (activeTab === "retailers") {
fetchData("retailers/");
}
});
let showModal = $state(false);
function openModal() {
showModal = true;
}
function closeModal() {
showModal = false;
}
let formdata: Retailer = {
shopname: "",
url: "",
id: 0,
country: "",
display: 0,
};
function submitUserForm(data: Retailer) {
closeModal();
console.log("User Form Submitted:", data);
}
function removeRowHandler(id: Number) {
console.log("Remove Record:", id);
}
</script>
<!-- Bootstrap Modal -->
<div
class="modal fade {showModal ? 'show' : ''}"
tabindex="-1"
role="dialog"
style="display: {showModal ? 'block' : 'none'};"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Retailer</h5>
</div>
<div class="modal-body">
<GenericForm formData={formdata} onSubmit={submitUserForm}>
{#each columns as col}
<div class="mb-3">
<label for={col}>{col}:</label>
<input
id={col}
type="text"
bind:value={formdata[col as keyof Retailer]}
/>
</div>
{/each}
</GenericForm>
</div>
<div class="modal-footer justify-content-end">
<button type="button" class="btn btn-secondary" onclick={closeModal}
>Close</button
>
</div>
</div>
</div>
</div>
<main>
<div>
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
</a>
<ul class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link {activeTab === 'invoice' ? 'active' : ''}"
href="#"
onclick={() => (activeTab = "invoice")}>Invoices</a
>
</li>
<li class="nav-item">
<a
class="nav-link {activeTab === 'retailers' ? 'active' : ''}"
href="#"
onclick={() => (activeTab = "retailers")}>Retailers</a
>
</li>
<li class="nav-item">
<a
class="nav-link {activeTab === 'news' ? 'active' : ''}"
href="#"
onclick={() => (activeTab = "news")}>News</a
>
</li>
</ul>
<div class="container-fluid">
{#if activeTab === "invoice"}
<p>Invoice list</p>
{/if}
{#if activeTab === "retailers"}
<div class="row justify-content-start">
<div class="col-2 mt-3">
<button
onclick={openModal}
type="button"
class="btn btn-outline-primary">Add retailer</button
>
</div>
</div>
<div class="row">
<div class="col">
<Table {columns} {records} {removeRowHandler} />
</div>
</div>
{/if}
{#if activeTab === "news"}
<p>news</p>
{/if}
</div>
<h1>Vite + Svelte</h1>
<div class="card">
<Counter />
</div>
<p>
Check out <a
href="https://github.com/sveltejs/kit#readme"
target="_blank"
rel="noreferrer">SvelteKit</a
>, the official Svelte app framework powered by Vite!
</p>
<p class="read-the-docs">Click on the Vite and Svelte logos to learn more</p>
</main>
<style>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.svelte:hover {
filter: drop-shadow(0 0 2em #ff3e00aa);
}
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,14 @@
<script lang="ts" generics="T">
export let formData: T;
export let onSubmit: (data: T) => void;
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onSubmit(formData);
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<slot />
<button type="submit">Submit</button>
</form>

View File

@ -0,0 +1,48 @@
<script lang="ts">
interface Props {
columns: string[];
records: any;
removeRowHandler: (id: number) => void;
}
let { columns, records, removeRowHandler }: Props = $props();
</script>
<table class="table">
<thead>
<tr>
{#each columns as col}
<th scope="col">{col}</th>
{/each}
<th scope="col">edit</th>
<th scope="col">remove</th>
</tr>
</thead>
<tbody>
{#each records as row}
<tr>
{#each columns as col}
<td>{row[col]}</td>
{/each}
<td>
<button
onclick={() => removeRowHandler(row.id)}
class="btn btn-outline-success align-items-center"
aria-label="Edit record"
>
<i class="bi bi-pencil"></i>
</button>
</td>
<td>
<button
onclick={() => removeRowHandler(row.id)}
class="btn btn-outline-danger align-items-center"
aria-label="Remove record"
>
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>

View File

@ -4,4 +4,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
build: {
manifest: true
}
})

View File

@ -2,6 +2,8 @@ package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
"text/template"
@ -19,9 +21,17 @@ type PageData struct {
//go:embed templates
var Templates embed.FS
//go:embed frontend/dist
//go:embed all:frontend/dist
var StaticAssets embed.FS
func DistFS() fs.FS {
efs, err := fs.Sub(StaticAssets, "frontend/dist")
if err != nil {
panic(fmt.Sprintf("unable to serve frontend: %v", err))
}
return efs
}
func RenderTemplate(w http.ResponseWriter, name string, data PageData) error {
tpl := template.Must(template.ParseFS(Templates, "templates/"+name+".html", "templates/base.html"))
return tpl.ExecuteTemplate(w, "base", data)

View File

@ -1,3 +1,3 @@
{{define "vite"}} {{ .Vite.Tags }} {{end}} {{define "content"}}
<div id="root"></div>
<div id="app"></div>
{{end}}