Compare commits
	
		
			32 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 783d59805b | |||
| b5f0fe8985 | |||
| d9bf79d5f8 | |||
| f98ab149a2 | |||
| 822ca2b8ab | |||
| af65180893 | |||
| 5615210be5 | |||
| b88fb1643c | |||
| 92189a4a51 | |||
| 8dc8f02504 | |||
| e3ce1d7b55 | |||
| 532bc6490a | |||
| 84fa828b38 | |||
| a3c53b1b20 | |||
| ca70fa6d4d | |||
| 972b8cac19 | |||
| d0605660f7 | |||
| 5d2d841aba | |||
| d62c5a4078 | |||
| 803c5bbdbd | |||
| c74bdeba72 | |||
| 717f1c813b | |||
| 52797760bb | |||
| 8711ba0629 | |||
| ed51d28c65 | |||
| 7e7de28b14 | |||
| 0139f7ab9a | |||
| 7fc115bcc3 | |||
| ae90f693f6 | |||
| a730e11b4a | |||
| 959e1e96b3 | |||
| 68b052625f | 
							
								
								
									
										54
									
								
								.air.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								.air.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| root = "." | ||||
| testdata_dir = "testdata" | ||||
| tmp_dir = "tmp" | ||||
|  | ||||
| [build] | ||||
| args_bin = [ | ||||
|     "-desc 'Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität'", | ||||
|     "-domain localhost:8080", | ||||
|     "-key tmp/key.gob", | ||||
|     "-link https://distrikt-ni-st.de", | ||||
|     "-log tmp/cpolis.log", | ||||
|     "-pics tmp/pics", | ||||
|     "-rss tmp/orientexpress_alle.rss", | ||||
|     "-title 'Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt'", | ||||
|     "-web web" | ||||
| ] | ||||
| bin = "./tmp/main" | ||||
| cmd = "go build -o ./tmp/main ./cmd/main.go" | ||||
| delay = 0 | ||||
| exclude_dir = ["assets", "tmp", "vendor", "testdata"] | ||||
| exclude_file = [] | ||||
| exclude_regex = ["_test.go"] | ||||
| exclude_unchanged = false | ||||
| follow_symlink = false | ||||
| full_bin = "" | ||||
| include_dir = [] | ||||
| include_ext = ["go", "tpl", "tmpl", "html", "css"] | ||||
| include_file = [] | ||||
| kill_delay = "0s" | ||||
| log = "build-errors.log" | ||||
| poll = false | ||||
| poll_interval = 0 | ||||
| rerun = false | ||||
| rerun_delay = 500 | ||||
| send_interrupt = false | ||||
| stop_on_error = false | ||||
|  | ||||
| [color] | ||||
| app = "" | ||||
| build = "yellow" | ||||
| main = "magenta" | ||||
| runner = "green" | ||||
| watcher = "cyan" | ||||
|  | ||||
| [log] | ||||
| main_only = false | ||||
| time = false | ||||
|  | ||||
| [misc] | ||||
| clean_on_exit = false | ||||
|  | ||||
| [screen] | ||||
| clear_on_rebuild = false | ||||
| keep_scroll = true | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -23,3 +23,4 @@ go.work | ||||
|  | ||||
| # Custom stuff | ||||
| tmp/ | ||||
| style.css | ||||
|   | ||||
| @@ -7,12 +7,16 @@ import ( | ||||
| ) | ||||
|  | ||||
| type CliArgs struct { | ||||
| 	Description string | ||||
| 	DBName      string | ||||
| 	Domain      string | ||||
| 	KeyFile     string | ||||
| 	Link        string | ||||
| 	LogFile     string | ||||
| 	Port        string | ||||
| 	PicsDir     string | ||||
| 	RSSFile     string | ||||
| 	Title       string | ||||
| 	WebDir      string | ||||
| } | ||||
|  | ||||
| @@ -20,13 +24,17 @@ func HandleCliArgs() (*CliArgs, error) { | ||||
| 	var err error | ||||
| 	cliArgs := new(CliArgs) | ||||
|  | ||||
| 	keyFile := flag.String("key", "/var/www/cpolis/cpolis.key", "key file") | ||||
| 	logFile := flag.String("log", "/var/log/cpolis.log", "log file") | ||||
| 	picsDir := flag.String("pics", "/var/www/cpolis/pics", "pictures directory") | ||||
| 	cliArgs.Port = fmt.Sprint(":", flag.Int("port", 8080, "port")) | ||||
| 	rssFile := flag.String("rss", "/var/www/cpolis/cpolis.rss", "RSS file") | ||||
| 	webDir := flag.String("web", "/var/www/cpolis/web", "web directory") | ||||
| 	flag.StringVar(&cliArgs.DBName, "db", "cpolis", "DB name") | ||||
| 	flag.StringVar(&cliArgs.Description, "desc", "Description", "Channel description") | ||||
| 	flag.StringVar(&cliArgs.Domain, "domain", "", "domain name") | ||||
| 	keyFile := flag.String("key", "/var/www/cpolis/cpolis.key", "key file") | ||||
| 	flag.StringVar(&cliArgs.Link, "link", "Link", "Channel Link") | ||||
| 	logFile := flag.String("log", "/var/log/cpolis.log", "log file") | ||||
| 	flag.StringVar(&cliArgs.PicsDir, "pics", "pics", "pictures directory") | ||||
| 	port := flag.Int("port", 8080, "port") | ||||
| 	rssFile := flag.String("rss", "/var/www/cpolis/cpolis.rss", "RSS file") | ||||
| 	flag.StringVar(&cliArgs.Title, "title", "Title", "Channel title") | ||||
| 	webDir := flag.String("web", "/var/www/cpolis/web", "web directory") | ||||
| 	flag.Parse() | ||||
|  | ||||
| 	cliArgs.KeyFile, err = filepath.Abs(*keyFile) | ||||
| @@ -39,11 +47,13 @@ func HandleCliArgs() (*CliArgs, error) { | ||||
| 		return nil, fmt.Errorf("error finding absolute path for LogFile: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	cliArgs.PicsDir, err = filepath.Abs(*picsDir) | ||||
| 	_, err = filepath.Abs(cliArgs.PicsDir) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error finding absolute path for PicsDir: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	cliArgs.Port = fmt.Sprint(":", *port) | ||||
|  | ||||
| 	cliArgs.RSSFile, err = filepath.Abs(*rssFile) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error finding absolute path for RSSFile: %v", err) | ||||
|   | ||||
| @@ -97,7 +97,7 @@ func GenerateRSS(db *model.DB, title, link, desc string) (*string, error) { | ||||
|  | ||||
| 		channel.Items = append(channel.Items, &rss.Item{ | ||||
| 			Title:       articleTitle, | ||||
| 			Author:      user.FirstName + user.LastName, | ||||
| 			Author:      user.FirstName + " " + user.LastName, | ||||
| 			PubDate:     article.Created.Format(time.RFC1123Z), | ||||
| 			Description: articleDescription, | ||||
| 			Content:     &rss.Content{Value: articleContent}, | ||||
| @@ -107,7 +107,7 @@ func GenerateRSS(db *model.DB, title, link, desc string) (*string, error) { | ||||
|  | ||||
| 	feed := rss.NewFeed() | ||||
| 	feed.Channels = append(feed.Channels, channel) | ||||
| 	rss, err := feed.ToXML() | ||||
| 	rss, err := feed.ToXML("UTF-8") | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error converting RSS feed to XML: %v", err) | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										53
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										53
									
								
								cmd/main.go
									
									
									
									
									
								
							| @@ -49,34 +49,33 @@ func main() { | ||||
| 		http.FileServer(http.Dir(args.WebDir+"/static/")))) | ||||
| 	mux.HandleFunc("/", view.HomePage(args, db, store)) | ||||
|  | ||||
| 	mux.HandleFunc("GET /create-tag/", view.CreateTag(args)) | ||||
| 	mux.HandleFunc("GET /create-user/", view.CreateUser(args)) | ||||
| 	mux.HandleFunc("GET /edit-user/", view.EditUser(args, db, store)) | ||||
| 	mux.HandleFunc("GET /hub/", view.ShowHub(args, db, store)) | ||||
| 	mux.HandleFunc("GET /logout/", view.Logout(args, store)) | ||||
| 	mux.HandleFunc("GET /publish-issue/", view.PublishLatestIssue(args, db, store)) | ||||
| 	mux.HandleFunc("GET /rejected-articles/", view.ShowRejectedArticles(args, db, store)) | ||||
| 	mux.HandleFunc("GET /rss/", view.ShowRSS(args, | ||||
| 		db, | ||||
| 		"Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt", | ||||
| 		"https://distrikt-ni-st.de", | ||||
| 		"Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität", | ||||
| 	)) | ||||
| 	mux.HandleFunc("GET /this-issue/", view.ShowCurrentArticles(args, db)) | ||||
| 	mux.HandleFunc("GET /unpublished-articles/", view.ShowUnpublishedArticles(args, db)) | ||||
| 	mux.HandleFunc("GET /write-article/", view.WriteArticle(args, db)) | ||||
| 	mux.HandleFunc("GET /create-tag", view.CreateTag(args)) | ||||
| 	mux.HandleFunc("GET /create-user", view.CreateUser(args)) | ||||
| 	mux.HandleFunc("GET /edit-user", view.EditUser(args, db, store)) | ||||
| 	mux.HandleFunc("GET /hub", view.ShowHub(args, db, store)) | ||||
| 	mux.HandleFunc("GET /logout", view.Logout(args, store)) | ||||
| 	mux.HandleFunc("GET /publish-article/{id}", view.PublishArticle(args, db, store)) | ||||
| 	mux.HandleFunc("GET /publish-issue", view.PublishLatestIssue(args, db, store)) | ||||
| 	mux.HandleFunc("GET /reject-article/{id}", view.RejectArticle(args, db, store)) | ||||
| 	mux.HandleFunc("GET /rejected-articles", view.ShowRejectedArticles(args, db, store)) | ||||
| 	mux.HandleFunc("GET /review-rejected-article/{id}", view.ReviewRejectedArticle(args, db, store)) | ||||
| 	mux.HandleFunc("GET /review-unpublished-article/{id}", view.ReviewUnpublishedArticle(args, db, store)) | ||||
| 	mux.HandleFunc("GET /rss", func(w http.ResponseWriter, r *http.Request) { | ||||
| 		http.ServeFile(w, r, args.RSSFile) | ||||
| 	}) | ||||
| 	mux.HandleFunc("GET /pics/{pic}", view.ServeImage(args, store)) | ||||
| 	mux.HandleFunc("GET /this-issue", view.ShowCurrentArticles(args, db)) | ||||
| 	mux.HandleFunc("GET /unpublished-articles", view.ShowUnpublishedArticles(args, db)) | ||||
| 	mux.HandleFunc("GET /write-article", view.WriteArticle(args, db)) | ||||
|  | ||||
| 	mux.HandleFunc("POST /add-tag/", view.AddTag(args, db, store)) | ||||
| 	mux.HandleFunc("POST /add-user/", view.AddUser(args, db, store)) | ||||
| 	mux.HandleFunc("POST /login/", view.Login(args, db, store)) | ||||
| 	mux.HandleFunc("POST /publish-article/", view.PublishArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /reject-article/", view.RejectArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /resubmit-article/", view.ResubmitArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /review-rejected-article/", view.ReviewRejectedArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /review-unpublished-article/", view.ReviewUnpublishedArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /submit-article/", view.SubmitArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /update-user/", view.UpdateUser(args, db, store)) | ||||
| 	mux.HandleFunc("POST /upload-image/", view.UploadImage(args)) | ||||
| 	mux.HandleFunc("POST /add-first-user", view.AddFirstUser(args, db, store)) | ||||
| 	mux.HandleFunc("POST /add-tag", view.AddTag(args, db, store)) | ||||
| 	mux.HandleFunc("POST /add-user", view.AddUser(args, db, store)) | ||||
| 	mux.HandleFunc("POST /login", view.Login(args, db, store)) | ||||
| 	mux.HandleFunc("POST /resubmit-article/{id}", view.ResubmitArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /submit-article", view.SubmitArticle(args, db, store)) | ||||
| 	mux.HandleFunc("POST /update-user", view.UpdateUser(args, db, store)) | ||||
| 	mux.HandleFunc("POST /upload-image", view.UploadImage(args)) | ||||
|  | ||||
| 	log.Fatalln(http.ListenAndServe(args.Port, mux)) | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"database/sql" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
|  | ||||
| @@ -198,3 +200,71 @@ func (db *DB) UpdateUserAttributes(id int64, user, first, last, oldPass, newPass | ||||
|  | ||||
| 	return fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries) | ||||
| } | ||||
|  | ||||
| func (db *DB) AddFirstUser(u *User, pass string) (int64, error) { | ||||
| 	var numUsers int64 | ||||
| 	txOptions := &sql.TxOptions{Isolation: sql.LevelSerializable} | ||||
| 	selectQuery := "SELECT COUNT(*) FROM users" | ||||
| 	insertQuery := ` | ||||
|     INSERT INTO users (username, password, first_name, last_name, role) | ||||
|     VALUES (?, ?, ?, ?, ?) | ||||
|     ` | ||||
|  | ||||
| 	for i := 0; i < TxMaxRetries; i++ { | ||||
| 		id, err := func() (int64, error) { | ||||
| 			tx, err := db.BeginTx(context.Background(), txOptions) | ||||
| 			if err != nil { | ||||
| 				return 0, fmt.Errorf("error starting transaction: %v", err) | ||||
| 			} | ||||
|  | ||||
| 			if err := tx.QueryRow(selectQuery).Scan(&numUsers); err != nil { | ||||
| 				if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 					log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr) | ||||
| 				} | ||||
| 				return 0, fmt.Errorf("error getting ID of %v: %v", u.UserName, err) | ||||
| 			} | ||||
| 			if numUsers != 0 { | ||||
| 				if err = tx.Commit(); err != nil { | ||||
| 					return 0, fmt.Errorf("error committing transaction: %v", err) | ||||
| 				} | ||||
| 				return 2, nil | ||||
| 			} | ||||
|  | ||||
| 			hashedPass, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) | ||||
| 			if err != nil { | ||||
| 				if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 					log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr) | ||||
| 				} | ||||
| 				return 0, fmt.Errorf("error creating password hash: %v", err) | ||||
| 			} | ||||
|  | ||||
| 			result, err := tx.Exec(insertQuery, u.UserName, string(hashedPass), u.FirstName, u.LastName, u.Role) | ||||
| 			if err != nil { | ||||
| 				if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 					log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr) | ||||
| 				} | ||||
| 				return 0, fmt.Errorf("error inserting new user %v into DB: %v", u.UserName, err) | ||||
| 			} | ||||
|  | ||||
| 			id, err := result.LastInsertId() | ||||
| 			if err != nil { | ||||
| 				if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 					log.Fatalf("transaction error: %v, rollback error: %v", err, rollbackErr) | ||||
| 				} | ||||
| 				return 0, fmt.Errorf("error inserting user into DB: %v", err) | ||||
| 			} | ||||
|  | ||||
| 			if err = tx.Commit(); err != nil { | ||||
| 				return 0, fmt.Errorf("error committing transaction: %v", err) | ||||
| 			} | ||||
| 			return id, nil | ||||
| 		}() | ||||
| 		if err == nil { | ||||
| 			return id, nil | ||||
| 		} | ||||
|  | ||||
| 		log.Println(err) | ||||
| 		wait(i) | ||||
| 	} | ||||
| 	return 0, fmt.Errorf("error: %v unsuccessful retries for DB operation, aborting", TxMaxRetries) | ||||
| } | ||||
|   | ||||
| @@ -7,9 +7,12 @@ import ( | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/google/uuid" | ||||
| 	"streifling.com/jason/cpolis/cmd/control" | ||||
| 	"streifling.com/jason/cpolis/cmd/model" | ||||
| ) | ||||
| @@ -92,7 +95,7 @@ func SubmitArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) htt | ||||
|  | ||||
| func ResubmitArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		id, err := strconv.ParseInt(r.PostFormValue("article-id"), 10, 64) | ||||
| 		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -197,26 +200,53 @@ func ShowRejectedArticles(c *control.CliArgs, db *model.DB, s *control.CookieSto | ||||
| func ReviewUnpublishedArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		type htmlData struct { | ||||
| 			Article *model.Article | ||||
| 			Title       string | ||||
| 			Description string | ||||
| 			Content     template.HTML | ||||
| 			Tags        []*model.Tag | ||||
| 			ID          int64 | ||||
| 		} | ||||
|  | ||||
| 		var err error | ||||
| 		data := new(htmlData) | ||||
|  | ||||
| 		id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) | ||||
| 		data.ID, err = strconv.ParseInt(r.PathValue("id"), 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		data.Article, err = db.GetArticle(id) | ||||
| 		article, err := db.GetArticle(data.ID) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		data.Tags, err = db.GetArticleTags(id) | ||||
| 		data.Title, err = control.ConvertToPlain(article.Title) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		data.Description, err = control.ConvertToPlain(article.Description) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		content, err := control.ConvertToHTML(article.Content) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		data.Content = template.HTML(content) | ||||
|  | ||||
| 		data.Tags, err = db.GetArticleTags(data.ID) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -238,7 +268,7 @@ func ReviewRejectedArticle(c *control.CliArgs, db *model.DB, s *control.CookieSt | ||||
| 		} | ||||
| 		data := new(htmlData) | ||||
|  | ||||
| 		id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) | ||||
| 		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -278,7 +308,7 @@ func ReviewRejectedArticle(c *control.CliArgs, db *model.DB, s *control.CookieSt | ||||
|  | ||||
| func PublishArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) | ||||
| 		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -308,12 +338,7 @@ func PublishArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) ht | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		feed, err := control.GenerateRSS( | ||||
| 			db, | ||||
| 			"Freimaurer Distrikt Niedersachsen und Sachsen-Anhalt", | ||||
| 			"https://distrikt-ni-st.de", | ||||
| 			"Freiheit, Gleichheit, Brüderlichkeit, Toleranz und Humanität", | ||||
| 		) | ||||
| 		feed, err := control.GenerateRSS(db, c.Title, c.Link, c.Description) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -333,7 +358,7 @@ func PublishArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) ht | ||||
|  | ||||
| func RejectArticle(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		id, err := strconv.ParseInt(r.PostFormValue("id"), 10, 64) | ||||
| 		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -385,8 +410,17 @@ func UploadImage(c *control.CliArgs) http.HandlerFunc { | ||||
| 		} | ||||
| 		defer file.Close() | ||||
|  | ||||
| 		filename := fmt.Sprint(c.PicsDir, time.Now().Format("2006-01-02_15:04:05"), "-", header.Filename) | ||||
| 		img, err := os.Create(filename) | ||||
| 		nameStrings := strings.Split(header.Filename, ".") | ||||
| 		extension := "." + nameStrings[len(nameStrings)-1] | ||||
| 		filename := fmt.Sprint(uuid.New(), extension) | ||||
| 		absFilepath, err := filepath.Abs(fmt.Sprint(c.PicsDir, "/", filename)) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		img, err := os.Create(absFilepath) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| @@ -400,7 +434,9 @@ func UploadImage(c *control.CliArgs) http.HandlerFunc { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		alt := strings.Join(nameStrings[0:len(nameStrings)-1], " ") | ||||
| 		imgMD := fmt.Sprint("") | ||||
| 		tmpl, err := template.ParseFiles(c.WebDir + "/templates/editor.html") | ||||
| 		template.Must(tmpl, err).ExecuteTemplate(w, "editor-images", fmt.Sprint("")) | ||||
| 		template.Must(tmpl, err).ExecuteTemplate(w, "editor-images", imgMD) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										22
									
								
								cmd/view/images.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								cmd/view/images.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| package view | ||||
|  | ||||
| import ( | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"path/filepath" | ||||
|  | ||||
| 	"streifling.com/jason/cpolis/cmd/control" | ||||
| ) | ||||
|  | ||||
| func ServeImage(c *control.CliArgs, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		absFilepath, err := filepath.Abs(c.PicsDir) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		http.ServeFile(w, r, absFilepath+"/"+r.PathValue("pic")) | ||||
| 	} | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| package view | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"html/template" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.streifling.com/jason/rss" | ||||
| 	"streifling.com/jason/cpolis/cmd/control" | ||||
| 	"streifling.com/jason/cpolis/cmd/model" | ||||
| ) | ||||
|  | ||||
| func ShowRSS(c *control.CliArgs, db *model.DB, title, link, desc string) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		channel := &rss.Channel{ | ||||
| 			Title:       title, | ||||
| 			Link:        link, | ||||
| 			Description: desc, | ||||
| 			Items:       make([]*rss.Item, 0), | ||||
| 		} | ||||
|  | ||||
| 		articles, err := db.GetCertainArticles(true, false) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		for _, article := range articles { | ||||
| 			tags, err := db.GetArticleTags(article.ID) | ||||
| 			if err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
| 			tagNames := make([]string, 0) | ||||
| 			for _, tag := range tags { | ||||
| 				tagNames = append(tagNames, tag.Name) | ||||
| 			} | ||||
| 			tagNames = append(tagNames, fmt.Sprint("Orient Express ", article.IssueID)) | ||||
|  | ||||
| 			user, err := db.GetUser(article.AuthorID) | ||||
| 			if err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			articleTitle, err := control.ConvertToPlain(article.Title) | ||||
| 			if err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			articleDescription, err := control.ConvertToPlain(article.Description) | ||||
| 			if err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			articleContent, err := control.ConvertToHTML(article.Content) | ||||
| 			if err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			channel.Items = append(channel.Items, &rss.Item{ | ||||
| 				Title:       articleTitle, | ||||
| 				Author:      user.FirstName + user.LastName, | ||||
| 				PubDate:     article.Created.Format(time.RFC1123Z), | ||||
| 				Description: articleDescription, | ||||
| 				Content:     &rss.Content{Value: articleContent}, | ||||
| 				Categories:  tagNames, | ||||
| 			}) | ||||
| 		} | ||||
|  | ||||
| 		feed := rss.NewFeed() | ||||
| 		feed.Channels = append(feed.Channels, channel) | ||||
| 		rss, err := feed.ToXML() | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		files := []string{c.WebDir + "/templates/index.html", c.WebDir + "/templates/feed.rss"} | ||||
| 		tmpl, err := template.ParseFiles(files...) | ||||
| 		template.Must(tmpl, err).Execute(w, rss) | ||||
| 	} | ||||
| } | ||||
| @@ -36,7 +36,7 @@ func HomePage(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.Han | ||||
|  | ||||
| 		files := []string{c.WebDir + "/templates/index.html"} | ||||
| 		if numRows == 0 { | ||||
| 			files = append(files, c.WebDir+"/templates/add-user.html") | ||||
| 			files = append(files, c.WebDir+"/templates/first-user.html") | ||||
| 			tmpl, err := template.ParseFiles(files...) | ||||
| 			template.Must(tmpl, err).Execute(w, nil) | ||||
| 		} else { | ||||
|   | ||||
| @@ -88,37 +88,13 @@ func AddUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.Hand | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		htmlData.ID, err = db.AddUser(htmlData.User, pass) | ||||
| 		_, err = db.AddUser(htmlData.User, pass) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if htmlData.ID == 1 { | ||||
| 			htmlData.Role = model.Admin | ||||
|  | ||||
| 			if err = db.UpdateAttributes( | ||||
| 				&model.Attribute{Table: "users", ID: id, AttName: "role", Value: htmlData.Role}, | ||||
| 			); err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			if err := saveSession(w, r, s, htmlData.User); err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			if _, err := db.AddIssue(); err != nil { | ||||
| 				log.Println(err) | ||||
| 				http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html") | ||||
| 		template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0) | ||||
| 	} | ||||
| @@ -214,3 +190,78 @@ func UpdateUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.H | ||||
| 		tmpl.ExecuteTemplate(w, "page-content", session.Values["role"].(int)) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func AddFirstUser(c *control.CliArgs, db *model.DB, s *control.CookieStore) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		var err error | ||||
|  | ||||
| 		htmlData := UserData{ | ||||
| 			User: &model.User{ | ||||
| 				UserName:  r.PostFormValue("username"), | ||||
| 				FirstName: r.PostFormValue("first-name"), | ||||
| 				LastName:  r.PostFormValue("last-name"), | ||||
| 				Role:      model.Admin, | ||||
| 			}, | ||||
| 		} | ||||
| 		pass := r.PostFormValue("password") | ||||
| 		pass2 := r.PostFormValue("password2") | ||||
|  | ||||
| 		if len(htmlData.UserName) == 0 || len(htmlData.FirstName) == 0 || | ||||
| 			len(htmlData.LastName) == 0 || len(pass) == 0 || len(pass2) == 0 { | ||||
| 			htmlData.Msg = "Alle Felder müssen ausgefüllt werden." | ||||
| 			tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html") | ||||
| 			template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData) | ||||
| 			return | ||||
| 		} | ||||
| 		userString, stringLen, ok := checkUserStrings(htmlData.User) | ||||
| 		if !ok { | ||||
| 			htmlData.Msg = fmt.Sprint(userString, " ist zu lang. Maximal ", | ||||
| 				stringLen, " Zeichen erlaubt.") | ||||
| 			tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html") | ||||
| 			template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData) | ||||
| 			return | ||||
| 		} | ||||
| 		id, _ := db.GetID(htmlData.UserName) | ||||
| 		if id != 0 { | ||||
| 			htmlData.Msg = fmt.Sprint(htmlData.UserName, | ||||
| 				" ist bereits vergeben. Bitte anderen Benutzernamen wählen.") | ||||
| 			tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html") | ||||
| 			template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData) | ||||
| 			return | ||||
| 		} | ||||
| 		if pass != pass2 { | ||||
| 			htmlData.Msg = "Die Passwörter stimmen nicht überein." | ||||
| 			tmpl, err := template.ParseFiles(c.WebDir + "/templates/add-user.html") | ||||
| 			template.Must(tmpl, err).ExecuteTemplate(w, "page-content", htmlData) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		htmlData.ID, err = db.AddFirstUser(htmlData.User, pass) | ||||
| 		if err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
| 		if htmlData.ID > 1 { | ||||
| 			errString := "error: there is already a first user" | ||||
| 			log.Println(errString) | ||||
| 			http.Error(w, errString, http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if err := saveSession(w, r, s, htmlData.User); err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if _, err := db.AddIssue(); err != nil { | ||||
| 			log.Println(err) | ||||
| 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		tmpl, err := template.ParseFiles(c.WebDir + "/templates/hub.html") | ||||
| 		template.Must(tmpl, err).ExecuteTemplate(w, "page-content", 0) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										50
									
								
								create_db.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								create_db.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| DROP TABLE IF EXISTS articles_tags; | ||||
| DROP TABLE IF EXISTS tags; | ||||
| DROP TABLE IF EXISTS articles; | ||||
| DROP TABLE IF EXISTS issues; | ||||
| DROP TABLE IF EXISTS users; | ||||
|  | ||||
| CREATE TABLE users ( | ||||
|     id          INT         AUTO_INCREMENT, | ||||
|     username    VARCHAR(15) NOT NULL UNIQUE, | ||||
|     password    VARCHAR(60) NOT NULL, | ||||
|     first_name  VARCHAR(50) NOT NULL, | ||||
|     last_name   VARCHAR(50) NOT NULL, | ||||
|     role        INT         NOT NULL, | ||||
|     PRIMARY KEY(id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE issues ( | ||||
|     id          INT     AUTO_INCREMENT, | ||||
|     published   BOOL    NOT NULL, | ||||
|     PRIMARY KEY(id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE articles ( | ||||
|     id          INT             AUTO_INCREMENT, | ||||
|     title       VARCHAR(255)    NOT NULL, | ||||
|     created     TIMESTAMP       DEFAULT CURRENT_TIMESTAMP, | ||||
|     description TEXT            NOT NULL, | ||||
|     content     TEXT            NOT NULL, | ||||
|     published   BOOL            NOT NULL, | ||||
|     rejected    BOOL            NOT NULL, | ||||
|     author_id   INT             NOT NULL, | ||||
|     issue_id    INT             NOT NULL, | ||||
|     PRIMARY KEY(id), | ||||
|     FOREIGN KEY(author_id) REFERENCES users(id), | ||||
|     FOREIGN KEY(issue_id) REFERENCES issues(id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE tags ( | ||||
|     id      INT         AUTO_INCREMENT, | ||||
|     name    VARCHAR(50) NOT NULL UNIQUE, | ||||
|     PRIMARY KEY(id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE articles_tags ( | ||||
|     article_id  INT, | ||||
|     tag_id      INT, | ||||
|     PRIMARY KEY(article_id, tag_id), | ||||
|     FOREIGN KEY(article_id) REFERENCES articles(id), | ||||
|     FOREIGN KEY(tag_id) REFERENCES tags(id) | ||||
| ); | ||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							| @@ -3,8 +3,9 @@ module streifling.com/jason/cpolis | ||||
| go 1.22.0 | ||||
|  | ||||
| require ( | ||||
| 	git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188 | ||||
| 	git.streifling.com/jason/rss v0.1.2 | ||||
| 	github.com/go-sql-driver/mysql v1.7.1 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/gorilla/sessions v1.2.2 | ||||
| 	github.com/microcosm-cc/bluemonday v1.0.26 | ||||
| 	github.com/yuin/goldmark v1.7.0 | ||||
|   | ||||
							
								
								
									
										6
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,11 +1,13 @@ | ||||
| git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188 h1:C8M/j3f+cl5Y7YfGpU/ynb/SC/4tTYMDsyGFt3rswM8= | ||||
| git.streifling.com/jason/rss v0.0.0-20240305164907-524bf9676188/go.mod h1:gpZF0nZbQSstMpyHD9DTAvlQEG7v4pjO5c7aIMWM4Jg= | ||||
| git.streifling.com/jason/rss v0.1.2 h1:UB3UHJXMt5WDDh9y8n0Z6nS1XortbPXjEr7QZTdovY4= | ||||
| git.streifling.com/jason/rss v0.1.2/go.mod h1:gpZF0nZbQSstMpyHD9DTAvlQEG7v4pjO5c7aIMWM4Jg= | ||||
| github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | ||||
| github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | ||||
| github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= | ||||
| github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= | ||||
| github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= | ||||
| github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||||
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= | ||||
| github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= | ||||
| github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= | ||||
|   | ||||
							
								
								
									
										10
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| /** @type {import('tailwindcss').Config} */ | ||||
| module.exports = { | ||||
|     content: ["./web/templates/*.html"], | ||||
|     theme: { | ||||
|         extend: {} | ||||
|     }, | ||||
|     plugins: [ | ||||
|         require('@tailwindcss/typography') | ||||
|     ], | ||||
| } | ||||
							
								
								
									
										41
									
								
								web/static/css/input.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								web/static/css/input.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
|  | ||||
| body { | ||||
|     width: 800px; | ||||
|     @apply mx-auto text-slate-900; | ||||
| } | ||||
|  | ||||
| h2 { | ||||
|     @apply font-bold mb-2 text-2xl; | ||||
| } | ||||
|  | ||||
| form { | ||||
|     @apply flex flex-col gap-y-3; | ||||
| } | ||||
|  | ||||
| input[type="file"] { | ||||
|     @apply border rounded-md w-full; | ||||
| } | ||||
|  | ||||
| input[type="password"], | ||||
| input[type="text"] { | ||||
|     @apply border h-8 rounded-md; | ||||
| } | ||||
|  | ||||
| textarea { | ||||
|     @apply border h-32 rounded-md; | ||||
| } | ||||
|  | ||||
| .btn-area { | ||||
|     @apply flex gap-4 mt-4; | ||||
| } | ||||
|  | ||||
| .btn { | ||||
|     @apply bg-slate-200 border my-2 px-3 py-2 rounded-md w-full hover:bg-slate-100; | ||||
| } | ||||
|  | ||||
| .action-btn { | ||||
|     @apply bg-slate-800 border my-2 px-3 py-2 rounded-md text-slate-50 w-full hover:bg-slate-700; | ||||
| } | ||||
| @@ -1,10 +1,12 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Neuer Benutzer</h2> | ||||
| <h2>Neuer Tag</h2> | ||||
|  | ||||
| <form> | ||||
|     <input required name="tag" placeholder="Tag" type="text" /> | ||||
|     <input type="submit" value="Anlegen" hx-post="/add-tag/" hx-target="#page-content" /> | ||||
|     <input required name="tag" placeholder="Tag eingeben" type="text" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-tag" hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button> | ||||
|     </div> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Abbrechen</button> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,33 +1,55 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Neuer Benutzer</h2> | ||||
|  | ||||
| <form> | ||||
|     <div class="grid grid-cols-3 gap-4"> | ||||
|         <div> | ||||
|         <input required name="username" placeholder="Benutzername" type="text" value="{{.UserName}}" /> | ||||
|         <input required name="password" placeholder="Passwort" type="password" /> | ||||
|         <input required name="password2" placeholder="Passwort wiederholen" type="password" /> | ||||
|     </div> | ||||
|  | ||||
|     <div> | ||||
|         <input required name="first-name" placeholder="Vorname" type="text" value="{{.FirstName}}" /> | ||||
|         <input required name="last-name" placeholder="Nachname" type="text" value="{{.LastName}}" /> | ||||
|             <label for="username">Benutzername</label> | ||||
|             <input class="w-full" required name="username" type="text" value="{{.UserName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password">Passwort</label> | ||||
|             <input class="w-full" required name="password" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password2">Passwort wiederholen</label> | ||||
|             <input class="w-full" required name="password2" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="first-name">Vorname</label> | ||||
|             <input class="w-full" required name="first-name" type="text" value="{{.FirstName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="last-name">Nachname</label> | ||||
|             <input class="w-full" required name="last-name" type="text" value="{{.LastName}}" /> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex gap-4"> | ||||
|         <div> | ||||
|             <input required id="author" name="role" type="radio" value="3" {{if eq .Role 3 }}checked{{end}} /> | ||||
|             <label for="author">Autor</label> | ||||
|         </div> | ||||
|         <div> | ||||
|             <input required id="editor" name="role" type="radio" value="2" {{if eq .Role 2 }}checked{{end}} /> | ||||
|             <label for="editor">Redakteur</label> | ||||
|         </div> | ||||
|         <div> | ||||
|             <input required id="publisher" name="role" type="radio" value="1" {{if eq .Role 1 }}checked{{end}} /> | ||||
|             <label for="publisher">Herausgeber</label> | ||||
|         </div> | ||||
|         <div> | ||||
|             <input required id="admin" name="role" type="radio" value="0" {{if eq .Role 0 }}checked{{end}} /> | ||||
|             <label for="admin">Admin</label> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Anlegen" hx-post="/add-user/" hx-target="#page-content" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-user" hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button> | ||||
|     </div> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Abbrechen</button> | ||||
|  | ||||
| <script> | ||||
|     var msg = "{{.Msg}}"; | ||||
|     if (msg != "") { | ||||
|   | ||||
| @@ -1,13 +1,17 @@ | ||||
| {{define "page-content"}} | ||||
| <div> | ||||
| <h2>Aktuelle Artikel</h2> | ||||
|  | ||||
| <div class="flex flex-col gap-4"> | ||||
|     {{range .}} | ||||
|     <div> | ||||
|         <h1>{{.Title}}</h1> | ||||
|     <div class="border px-2 py-1 rounded-md"> | ||||
|         <h1 class="font-bold text-2xl">{{.Title}}</h1> | ||||
|         <p>{{.Description}}</p> | ||||
|     </div> | ||||
|     {{end}} | ||||
| </div> | ||||
|  | ||||
| <button hx-get="/publish-issue/" hx-target="#page-content">Ausgabe publizieren</button> | ||||
| <button hx-get="/hub/" hx-target="#page-content">Abbrechen</button> | ||||
| <div class="btn-area"> | ||||
|     <button class="action-btn" hx-get="/publish-issue" hx-target="#page-content">Ausgabe publizieren</button> | ||||
|     <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button> | ||||
| </div> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,19 +1,38 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Benutzerdaten bearbeiten</h2> | ||||
|  | ||||
| <form> | ||||
|     <div class="grid grid-cols-3 gap-4"> | ||||
|         <div> | ||||
|         <input name="username" type="text" value="{{.UserName}}" /> | ||||
|         <input name="first-name" type="text" value="{{.FirstName}}" /> | ||||
|         <input name="last-name" type="text" value="{{.LastName}}" /> | ||||
|             <label for="username">Benutzername</label> | ||||
|             <input class="w-full" name="username" type="text" value="{{.UserName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="first-name">Vorname</label> | ||||
|             <input class="w-full" name="first-name" type="text" value="{{.FirstName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="last-name">Nachname</label> | ||||
|             <input class="w-full" name="last-name" type="text" value="{{.LastName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="old-password">Altes Passwort</label> | ||||
|             <input class="w-full" name="old-password" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password">Passwort</label> | ||||
|             <input class="w-full" name="password" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password2">Passwort wiederholen</label> | ||||
|             <input class="w-full" name="password2" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div> | ||||
|         <input name="old-password" placeholder="Altes Passwort" type="password" /> | ||||
|         <input name="password" placeholder="Neues Passwort" type="password" /> | ||||
|         <input name="password2" placeholder="Wiederholen" type="password" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Aktualisieren" hx-post="/update-user" | ||||
|             hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button> | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Aktualisieren" hx-post="/update-user/" hx-target="#page-content" /> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Abbrechen</button> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,13 +1,23 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Editor</h2> | ||||
|  | ||||
| <form> | ||||
|     <div> | ||||
|         <input name="article-title" placeholder="Titel" type="text" /> | ||||
|         <textarea name="article-description" placeholder="Beschreibung"></textarea> | ||||
|         <textarea name="article-content" placeholder="Artikel"></textarea> | ||||
|     <div class="flex flex-col gap-y-1"> | ||||
|         <label for="article-title">Titel</label> | ||||
|         <input name="article-title" type="text" /> | ||||
|     </div> | ||||
|     <div class="flex flex-col"> | ||||
|         <label for="article-description">Beschreibung</label> | ||||
|         <textarea name="article-description"></textarea> | ||||
|     </div> | ||||
|     <div class="flex flex-col"> | ||||
|         <label for="article-content">Artikel</label> | ||||
|         <textarea name="article-content"></textarea> | ||||
|     </div> | ||||
|  | ||||
|     <div> | ||||
|         <span>Tags</span> | ||||
|         <div class="flex flex-wrap gap-x-4"> | ||||
|             {{range .}} | ||||
|             <div> | ||||
|                 <input id="{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" /> | ||||
| @@ -15,17 +25,19 @@ | ||||
|             </div> | ||||
|             {{end}} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div id="editor-images"> | ||||
|         <input name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image/" | ||||
|         <input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image" | ||||
|             hx-swap="beforeend" hx-target="#editor-images" /> | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Senden" hx-post="/submit-article/" hx-target="#page-content" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Senden" hx-post="/submit-article" hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Abbrechen</button> | ||||
|     </div> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Abbrechen</button> | ||||
|  | ||||
| <script> | ||||
|     function copyToClipboard(text) { | ||||
|         event.preventDefault(); // Get-Request verhindern | ||||
| @@ -49,9 +61,10 @@ | ||||
|  | ||||
| {{define "editor-images"}} | ||||
| {{if gt (len .) 0}} | ||||
| <div> | ||||
|     {{.}} | ||||
|     <button onclick="copyToClipboard('{{.}}')">Kopieren</button> | ||||
| <div class="border px-2 py-1 rounded-md flex gap-4 justify-between"> | ||||
|     <div class="self-center">{{.}}</div> | ||||
|     <button class="bg-slate-50 border my-2 px-3 py-2 rounded-md w-32 hover:bg-slate-100" | ||||
|         onclick="copyToClipboard('{{.}}')">Kopieren</button> | ||||
| </div> | ||||
| {{end}} | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| {{define "page-content"}} | ||||
| {{.}} | ||||
| {{end}} | ||||
							
								
								
									
										39
									
								
								web/templates/first-user.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								web/templates/first-user.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Erster Benutzer (Administrator)</h2> | ||||
|  | ||||
| <form> | ||||
|     <div class="grid grid-cols-3 gap-4"> | ||||
|         <div> | ||||
|             <label for="username">Benutzername</label> | ||||
|             <input class="w-full" required name="username" type="text" value="{{.UserName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password">Passwort</label> | ||||
|             <input class="w-full" required name="password" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="password2">Passwort wiederholen</label> | ||||
|             <input class="w-full" required name="password2" placeholder="***" type="password" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="first-name">Vorname</label> | ||||
|             <input class="w-full" required name="first-name" type="text" value="{{.FirstName}}" /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <label for="last-name">Nachname</label> | ||||
|             <input class="w-full" required name="last-name" type="text" value="{{.LastName}}" /> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Anlegen" hx-post="/add-first-user" hx-target="#page-content" /> | ||||
|     </div> | ||||
| </form> | ||||
|  | ||||
| <script> | ||||
|     var msg = "{{.Msg}}"; | ||||
|     if (msg != "") { | ||||
|         alert(msg); | ||||
|     } | ||||
| </script> | ||||
| {{end}} | ||||
| @@ -1,25 +1,45 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Hub</h2> | ||||
| <div> | ||||
|     <button hx-get="/write-article/" hx-target="#page-content">Artikel schreiben</button> | ||||
|     <button hx-get="/rejected-articles/" hx-target="#page-content">Abgelehnte Artikel</button> | ||||
|     <button hx-get="/rss/" hx-target="#page-content">RSS Feed</button> | ||||
|     <button hx-get="/edit-user/" hx-target="#page-content">Benutzer bearbeiten</button> | ||||
| </div> | ||||
| {{if lt . 3}} | ||||
| <div> | ||||
|     <button hx-get="/unpublished-articles/" hx-target="#page-content">Unveröffentlichte Artikel</button> | ||||
|     <button hx-get="/create-tag/" hx-target="#page-content">Neuer Tag</button> | ||||
| <div class="flex flex-col gap-4"> | ||||
|     <button class="btn" hx-get="/logout" hx-target="#page-content">Abmelden</button> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|         <h2>Autor</h2> | ||||
|         <div class="grid grid-cols-2 gap-x-4 gap-y-2"> | ||||
|             <button class="btn" hx-get="/write-article" hx-target="#page-content">Artikel schreiben</button> | ||||
|             <button class="btn" hx-get="/rejected-articles" hx-target="#page-content">Abgelehnte Artikel</button> | ||||
|             <a class="btn text-center" href="/rss">RSS Feed</a> | ||||
|             <button class="btn" hx-get="/edit-user" hx-target="#page-content">Benutzer bearbeiten</button> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     {{if lt . 3}} | ||||
|     <div class="mb-3"> | ||||
|         <h2>Redakteur</h2> | ||||
|         <div class="grid grid-cols-2 gap-4"> | ||||
|             <button class="btn" hx-get="/unpublished-articles" hx-target="#page-content"> | ||||
|                 Unveröffentlichte Artikel | ||||
|             </button> | ||||
|             <button class="btn" hx-get="/create-tag" hx-target="#page-content">Neuer Tag</button> | ||||
|         </div> | ||||
|     </div> | ||||
|     {{end}} | ||||
|  | ||||
|     {{if lt . 2}} | ||||
|     <div class="mb-3"> | ||||
|         <h2>Herausgeber</h2> | ||||
|         <div class="grid grid-cols-2 gap-4"> | ||||
|             <button class="btn" hx-get="/this-issue" hx-target="#page-content">Diese Ausgabe</button> | ||||
|         </div> | ||||
|     </div> | ||||
|     {{end}} | ||||
|  | ||||
|     {{if eq . 0}} | ||||
|     <div class="mb-3"> | ||||
|         <h2>Administrator</h2> | ||||
|         <div class="grid grid-cols-2 gap-4"> | ||||
|             <button class="btn" hx-get="/create-user" hx-target="#page-content">Benutzer hinzufügen</button> | ||||
|         </div> | ||||
|     </div> | ||||
|     {{end}} | ||||
| </div> | ||||
| {{end}} | ||||
| {{if lt . 2}} | ||||
| <div> | ||||
|     <button hx-get="/this-issue/" hx-target="#page-content">Diese Ausgabe</button> | ||||
| </div> | ||||
| {{end}} | ||||
| {{if eq . 0}} | ||||
| <div> | ||||
|     <button hx-get="/create-user/" hx-target="#page-content">Benutzer hinzufügen</button> | ||||
| </div> | ||||
| {{end}} | ||||
| {{end}} | ||||
|   | ||||
| @@ -5,26 +5,27 @@ | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <title>Orient Editor</title> | ||||
|     <link href="web/static/css/style.css" rel="stylesheet"> | ||||
|     <link href="/web/static/css/style.css" rel="stylesheet"> | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     <header> | ||||
|         <h1>Orient Editor</h1> | ||||
|         <button hx-get="logout" hx-target="#page-content">Abmelden</button> | ||||
| <body class="flex flex-col justify-between min-h-screen bg-slate-50"> | ||||
|     <header class="my-8"> | ||||
|         <h1 class="font-bold text-4xl text-center">Orient Editor</h1> | ||||
|     </header> | ||||
|  | ||||
|     <main> | ||||
|     <main class="mx-4"> | ||||
|         <div id="page-content"> | ||||
|             {{template "page-content" .}} | ||||
|         </div> | ||||
|  | ||||
|         <script src="web/static/js/htmx.min.js"></script> | ||||
|     </main> | ||||
|  | ||||
|     <footer> | ||||
|         <p>© 2024 Jason Streifling. Alle Rechte vorbehalten.</p> | ||||
|     <footer class="my-8"> | ||||
|         <p class="text-center text-gray-500 dark:text-gray-400"> | ||||
|             © 2024 Jason Streifling. Alle Rechte vorbehalten. | ||||
|         </p> | ||||
|     </footer> | ||||
|  | ||||
|     <script src="/web/static/js/htmx.min.js"></script> | ||||
| </body> | ||||
|  | ||||
| </html> | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Anmeldung</h2> | ||||
|  | ||||
| <form> | ||||
|     <div> | ||||
|         <input name="username" placeholder="Benutzername" type="text" /> | ||||
|         <input name="password" placeholder="Passwort" type="password" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="w-full" name="username" placeholder="Benutzername" type="text" /> | ||||
|         <input class="w-full" name="password" placeholder="Passwort" type="password" /> | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Anmelden" hx-post="/login/" hx-target="#page-content" /> | ||||
|     <input class="action-btn" type="submit" value="Anmelden" hx-post="/login" hx-target="#page-content" /> | ||||
| </form> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,18 +1,16 @@ | ||||
| {{define "page-content"}} | ||||
| <form> | ||||
|     <div> | ||||
| <h2>Abgelehnte Artikel</h2> | ||||
|  | ||||
| <div class="flex flex-col gap-4"> | ||||
|     {{range .RejectedArticles}} | ||||
|         <div> | ||||
|     {{if index $.MyIDs .ID}} | ||||
|             <input required id="{{.ID}}" name="id" type="radio" value="{{.ID}}" /> | ||||
|             <label for="{{.ID}}">{{.Title}}</label> | ||||
|     <button class="btn" hx-get="/review-rejected-article/{{.ID}}" hx-target="#page-content"> | ||||
|         <h1 class="font-bold text-2xl">{{.Title}}</h1> | ||||
|         <p>{{.Description}}</p> | ||||
|     </button> | ||||
|     {{end}} | ||||
|         </div> | ||||
|     {{end}} | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Auswählen" hx-post="/review-rejected-article/" hx-target="#page-content" /> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Zurück</button> | ||||
|     <button class="action-btn" hx-get="/hub" hx-target="#page-content">Zurück</button> | ||||
| </div> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,14 +1,23 @@ | ||||
| {{define "page-content"}} | ||||
| <h2>Editor</h2> | ||||
|  | ||||
| <form> | ||||
|     <div> | ||||
|         <input name="article-title" placeholder="Titel" type="text" value="{{.Article.Title}}" /> | ||||
|         <textarea name="article-description" placeholder="Beschreibung">{{.Article.Description}}</textarea> | ||||
|     <div class="flex flex-col gap-y-1"> | ||||
|         <label for="article-title">Titel</label> | ||||
|         <input name="article-title" type="text" value="{{.Article.Title}}" /> | ||||
|     </div> | ||||
|     <div class="flex flex-col"> | ||||
|         <label for="article-description">Beschreibung</label> | ||||
|         <textarea name="article-description">{{.Article.Description}}</textarea> | ||||
|     </div> | ||||
|     <div class="flex flex-col"> | ||||
|         <label for="article-content">Artikel</label> | ||||
|         <textarea name="article-content" placeholder="Artikel">{{.Article.Content}}</textarea> | ||||
|         <input name="article-id" type="hidden" value="{{.Article.ID}}" /> | ||||
|     </div> | ||||
|  | ||||
|     <div> | ||||
|         <span>Tags</span> | ||||
|         <div class="flex flex-wrap gap-x-4"> | ||||
|             {{range .Tags}} | ||||
|             <div> | ||||
|                 <input id="tag-{{.Name}}" name="tags" type="checkbox" value="{{.ID}}" {{if index $.Selected | ||||
| @@ -17,17 +26,20 @@ | ||||
|             </div> | ||||
|             {{end}} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <div id="editor-images"> | ||||
|         <input name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image/" | ||||
|         <input class="mb-2" name="article-image" type="file" hx-encoding="multipart/form-data" hx-post="/upload-image" | ||||
|             hx-swap="beforeend" hx-target="#editor-images" /> | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Senden" hx-post="/resubmit-article/" hx-target="#page-content" /> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Senden" hx-post="/resubmit-article/{{.Article.ID}}" | ||||
|             hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button> | ||||
|     </div> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Zurück</button> | ||||
|  | ||||
| <script> | ||||
|     function copyToClipboard(text) { | ||||
|         event.preventDefault(); // Get-Request verhindern | ||||
| @@ -51,9 +63,10 @@ | ||||
|  | ||||
| {{define "editor-images"}} | ||||
| {{if gt (len .) 0}} | ||||
| <div> | ||||
|     {{.}} | ||||
|     <button onclick="copyToClipboard('{{.}}')">Kopieren</button> | ||||
| <div class="border px-2 py-1 rounded-md flex gap-4 justify-between"> | ||||
|     <div class="self-center">{{.}}</div> | ||||
|     <button class="bg-slate-50 border my-2 px-3 py-2 rounded-md w-32 hover:bg-slate-100" | ||||
|         onclick="copyToClipboard('{{.}}')">Kopieren</button> | ||||
| </div> | ||||
| {{end}} | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,19 +1,37 @@ | ||||
| {{define "page-content"}} | ||||
| <form> | ||||
|     <h2>{{.Article.Title}}</h2> | ||||
|     <p>{{.Article.Description}}</p> | ||||
|     {{.Article.Content}} | ||||
| <h2>Artikel veröffentlichen</h2> | ||||
|  | ||||
|     <p> | ||||
| <div> | ||||
|     <span>Titel</span> | ||||
|     <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full"> | ||||
|         {{.Title}} | ||||
|     </div> | ||||
|  | ||||
|     <span>Beschreibung</span> | ||||
|     <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full"> | ||||
|         {{.Description}} | ||||
|     </div> | ||||
|  | ||||
|     <span>Artikel</span> | ||||
|     <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full"> | ||||
|         <div class="prose"> | ||||
|             {{.Content}} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <span>Tags</span> | ||||
|     <div class="bg-white border mb-3 px-2 py-2 rounded-md w-full"> | ||||
|         {{range .Tags}} | ||||
|         {{.Name}} | ||||
|         <br> | ||||
|         {{end}} | ||||
|     </p> | ||||
|     </div> | ||||
|  | ||||
|     <input name="id" type="hidden" value="{{.Article.ID}}" /> | ||||
|     <input type="submit" value="Veröffentlichen" hx-post="/publish-article/" hx-target="#page-content" /> | ||||
|     <input type="submit" value="Ablehnen" hx-post="/reject-article/" hx-target="#page-content" /> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Zurück</button> | ||||
|     <div class="btn-area"> | ||||
|         <input class="action-btn" type="submit" value="Veröffentlichen" hx-get="/publish-article/{{.ID}}" | ||||
|             hx-target="#page-content" /> | ||||
|         <input class="btn" type="submit" value="Ablehnen" hx-get="/reject-article/{{.ID}}" hx-target="#page-content" /> | ||||
|         <button class="btn" hx-get="/hub" hx-target="#page-content">Zurück</button> | ||||
|     </div> | ||||
| </div> | ||||
| {{end}} | ||||
|   | ||||
| @@ -1,16 +1,13 @@ | ||||
| {{define "page-content"}} | ||||
| <form> | ||||
|     <div> | ||||
| <h2>Unveröffentlichte Artikel</h2> | ||||
|  | ||||
| <div class="flex flex-col gap-4"> | ||||
|     {{range .}} | ||||
|         <div> | ||||
|             <input required id="{{.ID}}" name="id" type="radio" value="{{.ID}}" /> | ||||
|             <label for="{{.ID}}">{{.Title}}</label> | ||||
|         </div> | ||||
|     <button class="btn" hx-get="/review-unpublished-article/{{.ID}}" hx-target="#page-content"> | ||||
|         <h1 class="font-bold text-2xl">{{.Title}}</h1> | ||||
|         <p>{{.Description}}</p> | ||||
|     </button> | ||||
|     {{end}} | ||||
|     </div> | ||||
|  | ||||
|     <input type="submit" value="Auswählen" hx-post="/review-unpublished-article/" hx-target="#page-content" /> | ||||
| </form> | ||||
|  | ||||
| <button hx-get="/hub/" hx-target="#page-content">Zurück</button> | ||||
|     <button class="action-btn" hx-get="/hub" hx-target="#page-content">Zurück</button> | ||||
| </div> | ||||
| {{end}} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user