feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
33
backend/cmd/import-articles/main.go
Normal file
33
backend/cmd/import-articles/main.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"osaet/backend/internal/postimport"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("import-articles", flag.ExitOnError)
|
||||
file := fs.String("file", "articles.csv", "CSV file path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite existing markdown files")
|
||||
postsDir := fs.String("posts-dir", "content/posts", "posts output directory relative to project root")
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result, err := postimport.Import(postimport.Options{
|
||||
CSVPath: *file,
|
||||
PostsDir: *postsDir,
|
||||
Overwrite: *overwrite,
|
||||
WorkingDir: "",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("imported %d post(s), skipped %d existing file(s), skipped %d non-post row(s)\n", result.Imported, result.SkippedExisting, result.SkippedNonPost)
|
||||
}
|
||||
116
backend/cmd/osaet-admin/main.go
Normal file
116
backend/cmd/osaet-admin/main.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osaet/backend/internal/admin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
command := "serve"
|
||||
if len(os.Args) > 1 {
|
||||
command = os.Args[1]
|
||||
}
|
||||
|
||||
cfg := admin.LoadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := admin.OpenDatabase(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch command {
|
||||
case "serve":
|
||||
return serve(ctx, cfg, db)
|
||||
case "migrate":
|
||||
return admin.RunMigrations(ctx, db, cfg.MigrationsDir)
|
||||
case "create-user":
|
||||
return createUser(ctx, db)
|
||||
case "import-markdown":
|
||||
return importMarkdown(ctx, cfg, db)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, db *pgxpool.Pool) error {
|
||||
if len(os.Args) < 3 {
|
||||
return errors.New("usage: osaet-admin create-user <username>")
|
||||
}
|
||||
|
||||
password := os.Getenv("OSAET_ADMIN_PASSWORD")
|
||||
if password == "" {
|
||||
return errors.New("OSAET_ADMIN_PASSWORD is required")
|
||||
}
|
||||
|
||||
user, err := admin.NewStore(db).CreateOrUpdateUser(ctx, os.Args[2], password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "admin user %q is ready\n", user.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
postsDir := cfg.PostsDir
|
||||
if len(os.Args) >= 3 {
|
||||
postsDir = os.Args[2]
|
||||
}
|
||||
|
||||
result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
server := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: admin.NewServerWithContext(ctx, db, cfg).Router(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue