From 8e23d9dade945f87f5fc7fb15042a53a7eeb9a9e Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 14 Jun 2025 11:49:28 +0200 Subject: Refactor project structure --- src/chef/api/admin/category.clj | 35 +++++++ src/chef/api/admin/recipe.clj | 45 +++++++++ src/chef/components/search.clj | 49 ---------- src/chef/frontend/admin.clj | 103 ++++++++++++++++++++ src/chef/frontend/admin/recipe_editor.clj | 63 +++++++++++++ src/chef/frontend/visitor/home.clj | 41 ++++++++ src/chef/frontend/visitor/recipe.clj | 55 +++++++++++ src/chef/frontend/visitor/recipe/thumbnail.clj | 10 ++ src/chef/frontend/visitor/search.clj | 38 ++++++++ src/chef/logic/categories.clj | 72 ++++++++++++++ src/chef/logic/recipes.clj | 60 ++++++++++++ src/chef/pages/admin.clj | 118 ----------------------- src/chef/pages/admin/api.clj | 126 ------------------------- src/chef/pages/admin/recipe_editor.clj | 71 -------------- src/chef/pages/home.clj | 51 ---------- src/chef/pages/recipe.clj | 77 --------------- src/chef/routes.clj | 56 ++++++----- src/chef/utils.clj | 37 +------- 18 files changed, 557 insertions(+), 550 deletions(-) create mode 100644 src/chef/api/admin/category.clj create mode 100644 src/chef/api/admin/recipe.clj delete mode 100644 src/chef/components/search.clj create mode 100644 src/chef/frontend/admin.clj create mode 100644 src/chef/frontend/admin/recipe_editor.clj create mode 100644 src/chef/frontend/visitor/home.clj create mode 100644 src/chef/frontend/visitor/recipe.clj create mode 100644 src/chef/frontend/visitor/recipe/thumbnail.clj create mode 100644 src/chef/frontend/visitor/search.clj create mode 100644 src/chef/logic/categories.clj create mode 100644 src/chef/logic/recipes.clj delete mode 100644 src/chef/pages/admin.clj delete mode 100644 src/chef/pages/admin/api.clj delete mode 100644 src/chef/pages/admin/recipe_editor.clj delete mode 100644 src/chef/pages/home.clj delete mode 100644 src/chef/pages/recipe.clj (limited to 'src') diff --git a/src/chef/api/admin/category.clj b/src/chef/api/admin/category.clj new file mode 100644 index 0000000..2d11510 --- /dev/null +++ b/src/chef/api/admin/category.clj @@ -0,0 +1,35 @@ +(ns chef.api.admin.category + (:require [chef.utils :as cutils] + [chef.logic.categories :as clcategories] + [ring.util.response :as ruresp])) + +;; POST / +(defn handle-edit [req] + (cutils/auth-only req + (if-let [id (cutils/s->int-or-nil (get-in req [:path-params :id]))] + (do (clcategories/update-category! id (merge {} + (when-let [name (get-in req [:params "name"])] + {:name name}) + (when-let [question (get-in req [:params "question"])] + {:question question}))) + (ruresp/response "Updated.")) + (ruresp/bad-request "Bad request.")))) + +;; DELETE / +(defn handle-delete [req] + (cutils/auth-only req + (let [id (cutils/s->int-or-nil (get-in req [:path-params :id]))] + (if (and (some? id) + (not= id -1)) + (do (clcategories/delete-category-and-children! id) + (-> (ruresp/response "Deleted.") + (ruresp/header "HX-Refresh" "true"))) + (ruresp/bad-request "Bad request."))))) + +;; POST /create +(defn handle-create [req] + (cutils/auth-only req + (clcategories/create-category! (or (get-in req [:params "parent"]) + -1)) + (-> (ruresp/created "Created.") + (ruresp/header "HX-Refresh" "true")))) diff --git a/src/chef/api/admin/recipe.clj b/src/chef/api/admin/recipe.clj new file mode 100644 index 0000000..c69ade5 --- /dev/null +++ b/src/chef/api/admin/recipe.clj @@ -0,0 +1,45 @@ +(ns chef.api.admin.recipe + (:require [chef.utils :as cutils] + [chef.logic.recipes :as clrecipes] + [ring.util.response :as ruresp])) + +;; POST / +(defn handle-edit [req] + (cutils/auth-only req + (let [id (cutils/s->int-or-nil (get-in req [:path-params :id])) + ingredients (get-in req [:params "ingredients"])] + (if (and (some? id) + (cutils/valid-ingredients? ingredients)) + (do (when-let [thumbnail (get-in req [:params "thumbnail"])] + (clrecipes/set-recipe-thumbnail! id thumbnail)) + (clrecipes/update-recipe! id {:title (get-in req [:params "title"]) + :category (get-in req [:params "category"]) + :unit (get-in req [:params "ingredients-unit"]) + :ingredients ingredients + :preparation (get-in req [:params "preparation"])}) + (ruresp/response "Saved.")) + (ruresp/bad-request "Bad request."))))) + +;; DELETE / +(defn handle-delete [req] + (cutils/auth-only req + (if-let [id (cutils/s->int-or-nil (get-in req [:path-params :id]))] + (do (clrecipes/delete-recipe! id) + (-> (ruresp/response "Deleted.") + (ruresp/header "HX-Refresh" "true"))) + (ruresp/bad-request "Bad request.")))) + +;; POST /create +(defn handle-create [req] + (cutils/auth-only req + (clrecipes/create-recipe!) + (-> (ruresp/created "Created.") + (ruresp/header "HX-Refresh" "true")))) + +;; DELETE /thumbnail +(defn handle-delete-thumbnail [req] + (cutils/auth-only req + (if-let [id (cutils/s->int-or-nil (get-in req [:path-params :id]))] + (do (clrecipes/remove-recipe-thumbnail! id) + (ruresp/response "Done.")) + (ruresp/bad-request "Bad request.")))) diff --git a/src/chef/components/search.clj b/src/chef/components/search.clj deleted file mode 100644 index e428a23..0000000 --- a/src/chef/components/search.clj +++ /dev/null @@ -1,49 +0,0 @@ -(ns chef.components.search - (:require [chef.database :as cdb] - [chef.utils :as cutils] - [clojure.string :as cstr] - [hiccup2.core :as html] - [honey.sql :as sql] - [next.jdbc :as jdbc] - [ring.util.response :as ruresp])) - -(defn render [query category] - [:table - [:tr - [:th "Rezept"] - [:th "Kategorie"]] - (for [recipe (jdbc/execute! @cdb/db - (sql/format {:select [:*] - :from [:recipes]})) - :let [recipe-category (->> {:select [:*] - :from [:categories] - :where [:= :id (:recipes/category recipe)]} - sql/format - (jdbc/execute! @cdb/db) - first)]] - (when (or (= category -1) - (and (cstr/includes? (-> recipe - :recipes/title - cstr/lower-case) - query) - (some #(= (:categories/id %) category) - (cutils/category-parents recipe-category)))) - [:tr - [:td - [:b [:a {:href (str "/recipes/" (:recipes/id recipe))} (:recipes/title recipe)]]] - [:td - (cutils/category-path (->> {:select [:*] - :from [:categories] - :where [:= :id (:recipes/category recipe)]} - sql/format - (jdbc/execute! @cdb/db) - first))]]))]) - -(defn handler [req] - (if-let [query (get-in req [:params "query"])] - (-> (render query (try (Integer/parseInt (get-in req [:params "category"])) - (catch Exception _ -1))) - html/html - str - ruresp/response) - (ruresp/bad-request "No search query provide."))) diff --git a/src/chef/frontend/admin.clj b/src/chef/frontend/admin.clj new file mode 100644 index 0000000..89a2462 --- /dev/null +++ b/src/chef/frontend/admin.clj @@ -0,0 +1,103 @@ +(ns chef.frontend.admin + (:require [chef.utils :as cutils] + [hiccup2.core :as html] + [ring.util.response :as ruresp] + + [chef.logic.categories :as clcategories] + [chef.logic.recipes :as clrecipes])) + +(defn- render-category [data children] + [:li + [:p {:style {:display :inline-block}} + (if (pos? (:categories/id data)) + [:input {:type :text :placeholder "Name" + :value (:categories/name data) + :name "name" + :hx-post (str "/api/admin/category/" (:categories/id data)) + :hx-trigger "change"}] + "Startseite")] + (when (or (neg? (:categories/id data)) + (->> (:categories/id data) + clcategories/find-categories-with-parent + count + pos?)) + (list [:p {:style {:display :inline-block + :margin-left "1em" + :margin-right "1em"}} "->"] + [:input {:type :text :placeholder "Frage" + :style {:display :inline-block + :width :auto} + :value (:categories/question data) + :name "question" + :hx-post (str "/api/admin/category/" (:categories/id data)) + :hx-trigger "change"}])) + [:img {:src "/static/icons/plus.svg" :height "30em" + :style {:vertical-align :middle + :margin-left "1em"} + :hx-post (str "/api/admin/category/create/" + (when (pos? (:categories/id data)) (str "?parent=" (:categories/id data)))) + :hx-swap "none"}] + (when (pos? (:categories/id data)) + [:img {:src "/static/icons/trash.svg" :height "30em" + :style {:vertical-align :middle + :margin-left "1em"} + :hx-delete (str "/api/admin/category/" (:categories/id data)) + :hx-swap "none"}]) + [:ul + (for [child children] + (->> (:categories/id child) + clcategories/find-categories-with-parent + (render-category child)))]]) + +(defn- render-recipe-table-row [recipe] + (let [tr-id (str "recipe-" (:recipes/id recipe))] + [:tr {:id tr-id} + [:td + [:p (:recipes/title recipe)]] + [:td + (let [category (clcategories/get-category (:recipes/category recipe))] + [:p (clcategories/generate-path category)])] + [:td + [:div + [:button {:class ["button" "primary"] + :onclick (str "window.open(\"/admin/recipe-editor/" + (:recipes/id recipe) + "\", \"\", \"width=900,height=900\")")} + "Bearbeiten"] + [:button {:class ["button error"] + :hx-trigger "click" + :hx-swap :none + :hx-delete (str "/api/admin/recipe/" (:recipes/id recipe))} + "Löschen"]]]])) + +(defn- render [] + (cutils/gen-page "chef - Admin" + [:div {:style {:margin-left "1em"}} + [:h1 "chef - Admin"] + [:h2 "Kategorien"] + [:ul + (render-category (clcategories/get-category -1) + (clcategories/find-categories-with-parent -1))] + [:h2 "Rezepte"] + [:table + [:tr + [:th "Titel"] + [:th "Kategorie"] + [:th "Aktionen"]] + (for [recipe (clrecipes/get-all-recipes)] + (render-recipe-table-row recipe))] + [:button {:class "button primary" + :hx-trigger "click" + :hx-swap :none + :hx-post "/api/admin/recipe/create/"} + "Rezept erstellen"]])) + +(defn handler [req] + (let [access-token (get-in req [:oauth2/access-tokens :auth]) + resp (-> (render) + html/html + str + ruresp/response)] + (if (some? access-token) + (assoc resp :session (assoc (:session req) :oauth-token access-token)) + (cutils/auth-only req resp)))) diff --git a/src/chef/frontend/admin/recipe_editor.clj b/src/chef/frontend/admin/recipe_editor.clj new file mode 100644 index 0000000..69c84ba --- /dev/null +++ b/src/chef/frontend/admin/recipe_editor.clj @@ -0,0 +1,63 @@ +(ns chef.frontend.admin.recipe-editor + (:require [hiccup2.core :as html] + [hiccup.util :as hutil] + [ring.util.response :as ruresp] + [chef.utils :as cutils] + + [chef.logic.categories :as clcategories] + [chef.logic.recipes :as clrecipes])) + +(defn render [recipe] + (cutils/gen-page "chef - Rezept bearbeiten" + [:div {:style {:margin-left "1em"}} + [:h1 "Rezept bearbeiten"] + [:form {:style {:width "50%"} + :hx-post (str "/api/admin/recipe/" (:recipes/id recipe)) + :hx-swap "none" + :enctype "multipart/form-data"} + [:input {:type :text :name "title" :placeholder "Titel" + :value (:recipes/title recipe)}] + [:div {:style {:display :flex}} + [:p {:style {:margin-right "0.5em"}} "Thumbnail: "] + [:input {:type :file :name "thumbnail" + :style {:height :fit-content + :padding "0.3em"}}]] + [:button {:class ["button" "error"] + :hx-trigger "click" + :hx-delete (str "/api/admin/recipe/" (:recipes/id recipe) "/thumbnail/") + :hx-swap :none} + "Thumbnail entfernen"] + [:h2 "Kategorie"] + [:select {:name "category"} + (for [category (clcategories/get-all-categories)] + [:option {:value (:categories/id category) + :selected (= (:categories/id category) (:recipes/category recipe))} + (clcategories/generate-path category)])] + [:h2 "Zutaten"] + [:div {:style {:display :flex}} + [:p {:style {:margin-right "0.5em"}} "Pro"] + [:select {:name "ingredients-unit" + :style {:height :fit-content + :padding "0.3em"}} + [:option {:value 0 :selected (= (:recipes/unit recipe) 0)} "Portion"] + [:option {:value 1 :selected (= (:recipes/unit recipe) 1)} "Person"]] + [:p ":"]] + [:textarea {:name "ingredients"} + (:recipes/ingredients recipe)] + [:p "(Je Zeile eine Zutat, in dem Format " [:code "[Beschreibung der Zutat]=[Menge als Zahl][Einheit der Menge]"] ".)"] + [:h2 "Zubereitung"] + [:textarea {:name "preparation"} + (:recipes/preparation recipe)] + [:button {:type :submit + :style {:margin-top "1em"}} "Speichern"]]] + [:script (hutil/raw-string "window.addEventListener(\"htmx:afterRequest\", () => window.close())")])) + +(defn handler [req] + (cutils/auth-only req + (if-let [id (cutils/s->int-or-nil (get-in req [:path-params :id]))] + (->> (clrecipes/get-recipe id) + render + html/html + str + ruresp/response) + (ruresp/bad-request "Bad request.")))) diff --git a/src/chef/frontend/visitor/home.clj b/src/chef/frontend/visitor/home.clj new file mode 100644 index 0000000..6503983 --- /dev/null +++ b/src/chef/frontend/visitor/home.clj @@ -0,0 +1,41 @@ +(ns chef.frontend.visitor.home + (:require [hiccup2.core :as html] + [ring.util.response :as ruresp] + [chef.utils :as cutils] + + [chef.frontend.visitor.search :as cfvsearch] + + [chef.logic.categories :as clcategories])) + +(defn- render [req] + (cutils/gen-page "chef" + (let [category (clcategories/get-category (or (get-in req [:params "category"]) -1))] + [:div {:style {:text-align :center}} + [:h1 "chef"] + [:h2 "Finde das perfekte Gericht für dich!"] + [:b (:categories/question category)] + [:div + (for [child-category (clcategories/find-categories-with-parent (:categories/id category))] + [:div + [:button {:style {:margin-bottom "1em"} + :onclick (str "window.location = \"/?category=" (:categories/id child-category) "\"")} + (:categories/name child-category)] + [:br]])] + (when (pos? (:categories/id category)) + [:h3 (clcategories/generate-path category)]) + [:input {:type :text + :style {:width "90%" :margin :auto} + :placeholder "Suche" + :hx-get (str "/components/search?category=" (:categories/id category)) + :name "query" + :hx-swap "innerHTML" + :hx-target "#search-results"}] + [:div {:id "search-results"} + (cfvsearch/render "" (:categories/id category))]]))) + +(defn handler [req] + (-> req + render + html/html + str + ruresp/response)) diff --git a/src/chef/frontend/visitor/recipe.clj b/src/chef/frontend/visitor/recipe.clj new file mode 100644 index 0000000..e3dbf97 --- /dev/null +++ b/src/chef/frontend/visitor/recipe.clj @@ -0,0 +1,55 @@ +(ns chef.frontend.visitor.recipe + (:require [chef.utils :as cutils] + [clojure.string :as cstr] + [hiccup2.core :as html] + [ring.util.response :as ruresp] + + [chef.logic.recipes :as clrecipes] + [chef.logic.categories :as clcategories])) + +(defn- render [portions recipe] + (cutils/gen-page (str "chef - " (:recipes/title recipe)) + [:div {:style {:margin-left "1em"}} + [:div + [:h1 {:style {:display :inline-block + :margin-right "0.5em"}} + (:recipes/title recipe)] + [:i (str "(" (-> (:recipes/category recipe) + clcategories/get-category + clcategories/generate-path) ")")]] + (when (some? (clrecipes/get-recipe-thumbnail (:recipes/id recipe))) + [:img {:src (str "/recipes/" (:recipes/id recipe) "/thumbnail") + :width "50%"}]) + [:h2 + "Zutaten pro " + [:input {:type :number + :value portions + :style {:width "3em" + :display :inline-block} + "_" "on change go to url `?portions=${value of me}`"}] + (condp = (:recipes/unit recipe) + 0 " Portion(en)" + 1 " Person(en)" + "") + ":"] + [:ul (for [ingredient (-> recipe + :recipes/ingredients + cutils/parse-ingredients)] + [:li + [:b (:description ingredient)] ": " + (* (:amount ingredient) portions) + (:unit ingredient)])] + [:h2 "Zubereitung"] + (->> (:recipes/preparation recipe) + cstr/split-lines + (map #(if (cstr/blank? %) + [:br] + [:p %])))])) + +(defn handler [req] + (->> (clrecipes/get-recipe (get-in req [:path-params :id])) + (render (or (cutils/s->int-or-nil (get-in req [:params "portions"])) + 1)) + html/html + str + ruresp/response)) diff --git a/src/chef/frontend/visitor/recipe/thumbnail.clj b/src/chef/frontend/visitor/recipe/thumbnail.clj new file mode 100644 index 0000000..c14f491 --- /dev/null +++ b/src/chef/frontend/visitor/recipe/thumbnail.clj @@ -0,0 +1,10 @@ +(ns chef.frontend.visitor.recipe.thumbnail + (:require [ring.util.response :as ruresp] + [chef.logic.recipes :as clrecipes]) + (:import java.io.File)) + +(defn handler [req] + (if-let [id (get-in req [:path-params :id])] + (when-let [thumbnail-file (clrecipes/get-recipe-thumbnail id)] + (ruresp/file-response (.getPath ^File thumbnail-file))) + (ruresp/bad-request "Bad request."))) diff --git a/src/chef/frontend/visitor/search.clj b/src/chef/frontend/visitor/search.clj new file mode 100644 index 0000000..7a2db93 --- /dev/null +++ b/src/chef/frontend/visitor/search.clj @@ -0,0 +1,38 @@ +(ns chef.frontend.visitor.search + (:require [chef.utils :as cutils] + [clojure.string :as cstr] + [hiccup2.core :as html] + [ring.util.response :as ruresp] + + [chef.logic.categories :as clcategories] + [chef.logic.recipes :as clrecipes])) + +(defn render [query category] + [:table + [:tr + [:th "Rezept"] + [:th "Kategorie"]] + (for [recipe (clrecipes/get-all-recipes) + :let [recipe-category (clcategories/get-category (:recipes/category recipe))]] + (when (or (= category -1) + (and (cstr/includes? (-> recipe + :recipes/title + cstr/lower-case) + query) + (some #(= (:categories/id %) category) + (clcategories/get-parents recipe-category)))) + [:tr + [:td + [:b [:a {:href (str "/recipes/" (:recipes/id recipe))} (:recipes/title recipe)]]] + [:td + (-> (:recipes/category recipe) + clcategories/get-category + clcategories/generate-path)]]))]) + +(defn handler [req] + (if-let [query (get-in req [:params "query"])] + (-> (render query (or (cutils/s->int-or-nil (get-in req [:params "category"])) -1)) + html/html + str + ruresp/response) + (ruresp/bad-request "No search query provide."))) diff --git a/src/chef/logic/categories.clj b/src/chef/logic/categories.clj new file mode 100644 index 0000000..aa9931e --- /dev/null +++ b/src/chef/logic/categories.clj @@ -0,0 +1,72 @@ +(ns chef.logic.categories + (:require [clojure.string :as cstr] + [honey.sql :as sql] + [next.jdbc :as jdbc] + [chef.database :as cdb])) + +(defn get-all-categories [] + (->> {:select [:*] + :from [:categories]} + sql/format + (jdbc/execute! @cdb/db) + (filter #(pos? (:categories/id %))) ; Filter out root category + )) + +(defn get-category [id] + (->> {:select [:*] + :from [:categories] + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db) + first)) + +(defn find-categories-with-parent [parent-id] + (->> {:select [:*] + :from [:categories] + :where [:= :parent parent-id]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn create-category! [parent] + (->> {:insert-into [:categories] + :values [{:name "New category" + :parent parent}]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn- delete-category! [id] + (->> {:delete-from [:categories] + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn- delete-category-children! [id] + (doseq [child (find-categories-with-parent id)] + (delete-category! (:categories/id child)) + (delete-category-children! (:categories/id child)))) + +(defn delete-category-and-children! [id] + (delete-category! id) + (delete-category-children! id)) + +(defn update-category! [id updates] + (->> {:update :categories + :set updates + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn get-parents [category] + (loop [parents (list) + category category] + (let [updated-parents (conj parents category)] + (if (not= -1 (:categories/parent category)) + (recur updated-parents + (get-category (:categories/parent category))) + updated-parents)))) + +(defn generate-path [category] + (->> category + get-parents + (map #(:categories/name %)) + (cstr/join " > "))) diff --git a/src/chef/logic/recipes.clj b/src/chef/logic/recipes.clj new file mode 100644 index 0000000..1fd1db5 --- /dev/null +++ b/src/chef/logic/recipes.clj @@ -0,0 +1,60 @@ +(ns chef.logic.recipes + (:require [clojure.java.io :as cjio] + [clojure.string :as cstr] + [honey.sql :as sql] + [next.jdbc :as jdbc] + [chef.database :as cdb]) + (:import java.io.File)) + +(defn get-all-recipes [] + (->> {:select [:*] + :from [:recipes]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn get-recipe [id] + (->> {:select [:*] + :from [:recipes] + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db) + first)) + +(defn create-recipe! [] + (->> {:insert-into [:recipes] + :values [{:title "New recipe"}]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn delete-recipe! [id] + (->> {:delete-from [:recipes] + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn update-recipe! [id updates] + (->> {:update :recipes + :set updates + :where [:= :id id]} + sql/format + (jdbc/execute! @cdb/db))) + +(defn get-recipe-thumbnail [recipe-id] + (let [thumbnails-folder (File. "./thumbnails/")] + (->> thumbnails-folder + .listFiles + (filter #(cstr/starts-with? (.getName ^File %) + (str recipe-id "."))) + first))) + +(defn remove-recipe-thumbnail! [id] + (when-let [file (get-recipe-thumbnail id)] + (.delete ^File file))) + +(defn set-recipe-thumbnail! [id multipart-param] + (when-let [existing-thumbnail-file (get-recipe-thumbnail id)] + (.delete ^File existing-thumbnail-file)) + (cjio/copy (:tempfile multipart-param) + (File. (str "./thumbnails/" id "." (-> (:filename multipart-param) + (cstr/split #"\.") + last))))) diff --git a/src/chef/pages/admin.clj b/src/chef/pages/admin.clj deleted file mode 100644 index ea19cc3..0000000 --- a/src/chef/pages/admin.clj +++ /dev/null @@ -1,118 +0,0 @@ -(ns chef.pages.admin - (:require [chef.utils :as cutils] - [hiccup2.core :as html] - [ring.util.response :as ruresp] - [chef.database :as cdb] - [next.jdbc :as jdbc] - [honey.sql :as sql])) - -(defn- render-category [data children] - [:li - [:p {:style {:display :inline-block}} - (if (pos? (:categories/id data)) - [:input {:type :text :placeholder "Name" - :value (:categories/name data) - :name "name" - :hx-post (str "/api/admin/edit-category/" (:categories/id data)) - :hx-trigger "change"}] - "Startseite")] - (when (or (neg? (:categories/id data)) - (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :parent (:categories/id data)]}) - (jdbc/execute! @cdb/db) - count - pos?)) - (list [:p {:style {:display :inline-block - :margin-left "1em" - :margin-right "1em"}} "->"] - [:input {:type :text :placeholder "Frage" - :style {:display :inline-block - :width :auto} - :value (:categories/question data) - :name "question" - :hx-post (str "/api/admin/edit-category/" (:categories/id data)) - :hx-trigger "change"}])) - [:img {:src "/static/icons/plus.svg" :height "30em" - :style {:vertical-align :middle - :margin-left "1em"} - :hx-post (str "/api/admin/create-category" - (when (pos? (:categories/id data)) (str "?parent=" (:categories/id data)))) - :hx-swap "none"}] - (when (pos? (:categories/id data)) - [:img {:src "/static/icons/trash.svg" :height "30em" - :style {:vertical-align :middle - :margin-left "1em"} - :hx-delete (str "/api/admin/delete-category/" (:categories/id data)) - :hx-swap "none"}]) - [:ul - (for [child children] - (render-category child (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :parent (:categories/id child)]}) - (jdbc/execute! @cdb/db))))]]) - -(defn- render-recipe-table-row [recipe] - (let [tr-id (str "recipe-" (:recipes/id recipe))] - [:tr {:id tr-id} - [:td - [:p (:recipes/title recipe)]] - [:td - (let [category (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :id (:recipes/category recipe)]}) - (jdbc/execute! @cdb/db) - first)] - [:p (cutils/category-path category)])] - [:td - [:div - [:button {:class ["button" "primary"] - :onclick (str "window.open(\"/admin/recipe-editor/" - (:recipes/id recipe) - "\", \"\", \"width=900,height=900\")")} - "Bearbeiten"] - [:button {:class ["button error"] - :hx-trigger "click" - :hx-swap :none - :hx-delete (str "/api/admin/delete-recipe/" (:recipes/id recipe))} - "Löschen"]]]])) - -(defn- render [] - (cutils/gen-page "chef - Admin" - [:div {:style {:margin-left "1em"}} - [:h1 "chef - Admin"] - [:h2 "Kategorien"] - [:ul - (render-category (first (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :id -1]}) - (jdbc/execute! @cdb/db))) - (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :parent -1]}) - (jdbc/execute! @cdb/db)))] - [:h2 "Rezepte"] - [:table - [:tr - [:th "Titel"] - [:th "Kategorie"] - [:th "Aktionen"]] - (for [recipe (jdbc/execute! @cdb/db - (sql/format {:select [:*] - :from [:recipes]}))] - (render-recipe-table-row recipe))] - [:button {:class "button primary" - :hx-trigger "click" - :hx-swap :none - :hx-post "/api/admin/create-recipe"} - "Rezept erstellen"]])) - -(defn handler [req] - (let [access-token (get-in req [:oauth2/access-tokens :auth]) - resp (-> (render) - html/html - str - ruresp/response)] - (if (some? access-token) - (assoc resp :session (assoc (:session req) :oauth-token access-token)) - (cutils/auth-only req resp)))) diff --git a/src/chef/pages/admin/api.clj b/src/chef/pages/admin/api.clj deleted file mode 100644 index 1119607..0000000 --- a/src/chef/pages/admin/api.clj +++ /dev/null @@ -1,126 +0,0 @@ -(ns chef.pages.admin.api - (:require [chef.utils :as cutils] - [chef.database :as cdb] - [clojure.string :as cstr] - [next.jdbc :as jdbc] - [honey.sql :as sql] - [ring.util.response :as ruresp] - [clojure.java.io :as cjio]) - (:import java.io.File)) - -(defn create-category [req] - (cutils/auth-only req - (jdbc/execute! @cdb/db - (sql/format {:insert-into [:categories] - :values [(merge {:name "New category" - :parent (or (get-in req [:params "parent"]) - -1)})]})) - (-> (ruresp/created "Created.") - (ruresp/header "HX-Refresh" "true")))) - -(defn- delete-category-children! [id] - (let [children (->> (sql/format {:select [:*] - :from [:categories] - :where [:= :parent id]}) - (jdbc/execute! @cdb/db) - (map #(:categories/id %)))] - (doseq [child children] - (jdbc/execute! @cdb/db - (sql/format {:delete-from [:categories] - :where [:= :id child]})) - (delete-category-children! child)))) - -(defn delete-category [req] - (cutils/auth-only req - (if-let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil))] - (when (not= id -1) - (do (jdbc/execute! @cdb/db - (sql/format {:delete-from [:categories] - :where [:= :id id]})) - (delete-category-children! id) - (-> (ruresp/response "Deleted.") - (ruresp/header "HX-Refresh" "true")))) - (ruresp/bad-request "Bad request.")))) - -(defn edit-category [req] - (cutils/auth-only req - (if-let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil))] - (do (when-let [name (get-in req [:params "name"])] - (jdbc/execute! @cdb/db (sql/format {:update :categories - :set {:name name} - :where [:= :id id]}))) - (when-let [question (get-in req [:params "question"])] - (jdbc/execute! @cdb/db (sql/format {:update :categories - :set {:question question} - :where [:= :id id]}))) - (ruresp/response "Updated.")) - (ruresp/bad-request "Bad request.")))) - -(defn create-recipe [req] - (cutils/auth-only req - (jdbc/execute! @cdb/db - (sql/format {:insert-into [:recipes] - :values [{:title "New recipe"}]})) - (-> (ruresp/created "Created.") - (ruresp/header "HX-Refresh" "true")))) - -(defn delete-recipe [req] - (cutils/auth-only req - (if-let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil))] - (do (jdbc/execute! @cdb/db - (sql/format {:delete-from [:recipes] - :where [:= :id id]})) - (-> (ruresp/response "Deleted.") - (ruresp/header "HX-Refresh" "true"))) - (ruresp/bad-request "Bad request.")))) - -(defn edit-recipe [req] - (cutils/auth-only req - (let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil)) - ingredients (get-in req [:params "ingredients"])] - (if (and (some? id) - (cutils/valid-ingredients? ingredients)) - (do (when-let [thumbnail (get-in req [:params "thumbnail"])] - (when-let [existing-thumbnail-file (->> {:select [:*] - :from [:recipes] - :where [:= :id id]} - sql/format - (jdbc/execute! @cdb/db) - first - cutils/get-thumbnail-file)] - (.delete ^File existing-thumbnail-file)) - (cjio/copy (:tempfile thumbnail) - (File. (str "./thumbnails/" id "." - (-> thumbnail - :filename - (cstr/split #"\.") - last))))) - (jdbc/execute! @cdb/db - (sql/format {:update :recipes - :set {:title (get-in req [:params "title"]) - :category (get-in req [:params "category"]) - :unit (get-in req [:params "ingredients-unit"]) - :ingredients ingredients - :preparation (get-in req [:params "preparation"])} - :where [:= :id id]})) - (ruresp/response "Saved.")) - (ruresp/bad-request "Bad request."))))) - -(defn delete-thumbnail [req] - (cutils/auth-only req - (if-let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil))] - (when-let [thumbnail-file (->> {:select [:*] - :from [:recipes] - :where [:= :id id]} - sql/format - (jdbc/execute! @cdb/db) - first - cutils/get-thumbnail-file)] - (.delete ^File thumbnail-file) - (ruresp/response "Done.")) - (ruresp/bad-request "Bad request.")))) diff --git a/src/chef/pages/admin/recipe_editor.clj b/src/chef/pages/admin/recipe_editor.clj deleted file mode 100644 index cc30942..0000000 --- a/src/chef/pages/admin/recipe_editor.clj +++ /dev/null @@ -1,71 +0,0 @@ -(ns chef.pages.admin.recipe-editor - (:require [hiccup2.core :as html] - [hiccup.util :as hutil] - [honey.sql :as sql] - [next.jdbc :as jdbc] - [chef.database :as cdb] - [ring.util.response :as ruresp] - [chef.utils :as cutils])) - -(defn render [recipe] - (cutils/gen-page "chef - Rezept bearbeiten" - [:div {:style {:margin-left "1em"}} - [:h1 "Rezept bearbeiten"] - [:form {:style {:width "50%"} - :hx-post (str "/api/admin/edit-recipe/" (:recipes/id recipe)) - :hx-swap "none" - :enctype "multipart/form-data"} - [:input {:type :text :name "title" :placeholder "Titel" - :value (:recipes/title recipe)}] - [:div {:style {:display :flex}} - [:p {:style {:margin-right "0.5em"}} "Thumbnail: "] - [:input {:type :file :name "thumbnail" - :style {:height :fit-content - :padding "0.3em"}}]] - [:button {:class ["button" "error"] - :hx-trigger "click" - :hx-delete (str "/api/admin/delete-thumbnail/" (:recipes/id recipe)) - :hx-swap :none} - "Thumbnail entfernen"] - [:h2 "Kategorie"] - [:select {:name "category"} - (for [category (->> (sql/format {:select [:*] - :from [:categories]}) - (jdbc/execute! @cdb/db) - (filter #(pos? (:categories/id %))))] - [:option {:value (:categories/id category) - :selected (= (:categories/id category) (:recipes/category recipe))} - (cutils/category-path category)])] - [:h2 "Zutaten"] - [:div {:style {:display :flex}} - [:p {:style {:margin-right "0.5em"}} "Pro"] - [:select {:name "ingredients-unit" - :style {:height :fit-content - :padding "0.3em"}} - [:option {:value 0 :selected (= (:recipes/unit recipe) 0)} "Portion"] - [:option {:value 1 :selected (= (:recipes/unit recipe) 1)} "Person"]] - [:p ":"]] - [:textarea {:name "ingredients"} - (:recipes/ingredients recipe)] - [:p "(Je Zeile eine Zutat, in dem Format " [:code "[Beschreibung der Zutat]=[Menge als Zahl][Einheit der Menge]"] ".)"] - [:h2 "Zubereitung"] - [:textarea {:name "preparation"} - (:recipes/preparation recipe)] - [:button {:type :submit - :style {:margin-top "1em"}} "Speichern"]]] - [:script (hutil/raw-string "window.addEventListener(\"htmx:afterRequest\", () => window.close())")])) - -(defn handler [req] - (cutils/auth-only req - (if-let [id (try (Integer/parseInt (get-in req [:path-params :id])) - (catch Exception _ nil))] - (->> (sql/format {:select [:*] - :from [:recipes] - :where [:= :id id]}) - (jdbc/execute! @cdb/db) - first - render - html/html - str - ruresp/response) - (ruresp/bad-request "Bad request.")))) diff --git a/src/chef/pages/home.clj b/src/chef/pages/home.clj deleted file mode 100644 index 9bc82fb..0000000 --- a/src/chef/pages/home.clj +++ /dev/null @@ -1,51 +0,0 @@ -(ns chef.pages.home - (:require [chef.database :as cdb] - [hiccup2.core :as html] - [honey.sql :as sql] - [next.jdbc :as jdbc] - [ring.util.response :as ruresp] - [chef.utils :as cutils] - - [chef.components.search :as ccsearch])) - -(defn- render [req] - (cutils/gen-page "chef" - (let [category (->> {:select [:*] - :from [:categories] - :where [:= :id (or (get-in req [:params "category"]) -1)]} - sql/format - (jdbc/execute! @cdb/db) - first)] - [:div {:style {:text-align :center}} - [:h1 "chef"] - [:h2 "Finde das perfekte Gericht für dich!"] - [:b (:categories/question category)] - [:div - (for [child-category (->> {:select [:*] - :from [:categories] - :where [:= :parent (:categories/id category)]} - sql/format - (jdbc/execute! @cdb/db))] - [:div - [:button {:style {:margin-bottom "1em"} - :onclick (str "window.location = \"/?category=" (:categories/id child-category) "\"")} - (:categories/name child-category)] - [:br]])] - (when (pos? (:categories/id category)) - [:h3 (cutils/category-path category)]) - [:input {:type :text - :style {:width "90%" :margin :auto} - :placeholder "Suche" - :hx-get (str "/components/search?category=" (:categories/id category)) - :name "query" - :hx-swap "innerHTML" - :hx-target "#search-results"}] - [:div {:id "search-results"} - (ccsearch/render "" (:categories/id category))]]))) - -(defn handler [req] - (-> req - render - html/html - str - ruresp/response)) diff --git a/src/chef/pages/recipe.clj b/src/chef/pages/recipe.clj deleted file mode 100644 index be88b2d..0000000 --- a/src/chef/pages/recipe.clj +++ /dev/null @@ -1,77 +0,0 @@ -(ns chef.pages.recipe - (:require [chef.database :as cdb] - [chef.utils :as cutils] - [clojure.string :as cstr] - [hiccup2.core :as html] - [honey.sql :as sql] - [next.jdbc :as jdbc] - [ring.util.response :as ruresp]) - (:import java.io.File)) - -(defn- render [portions recipe] - (cutils/gen-page (str "chef - " (:recipes/title recipe)) - [:div {:style {:margin-left "1em"}} - [:div - [:h1 {:style {:display :inline-block - :margin-right "0.5em"}} - (:recipes/title recipe)] - [:i (str "(" (cutils/category-path (->> {:select [:*] - :from [:categories] - :where [:= :id (:recipes/category recipe)]} - sql/format - (jdbc/execute! @cdb/db) - first)) ")")]] - (when (some? (cutils/get-thumbnail-file recipe)) - [:img {:src (str "/recipes/" (:recipes/id recipe) "/thumbnail") - :width "50%"}]) - [:h2 - "Zutaten pro " - [:input {:type :number - :value portions - :style {:width "3em" - :display :inline-block} - "_" "on change go to url `?portions=${value of me}`"}] - (condp = (:recipes/unit recipe) - 0 " Portion(en)" - 1 " Person(en)" - "") - ":"] - [:ul (for [ingredient (-> recipe - :recipes/ingredients - cutils/parse-ingredients)] - [:li - [:b (:description ingredient)] ": " - (* (:amount ingredient) portions) - (:unit ingredient)])] - [:h2 "Zubereitung"] - (->> (:recipes/preparation recipe) - cstr/split-lines - (map #(if (cstr/blank? %) - [:br] - [:p %])))])) - -(defn handler [req] - (->> {:select [:*] - :from [:recipes] - :where [:= :id (get-in req [:path-params :id])]} - sql/format - (jdbc/execute! @cdb/db) - first - (render (or (try (Integer/parseInt (get-in req [:params "portions"])) - (catch Exception _ nil)) - 1)) - html/html - str - ruresp/response)) - -(defn thumbnail-handler [req] - (if-let [id (get-in req [:path-params :id])] - (when-let [thumbnail-file (->> {:select [:*] - :from [:recipes] - :where [:= :id id]} - sql/format - (jdbc/execute! @cdb/db) - first - cutils/get-thumbnail-file)] - (ruresp/file-response (.getPath ^File thumbnail-file))) - (ruresp/bad-request "Bad request."))) diff --git a/src/chef/routes.clj b/src/chef/routes.clj index 084c7d7..4635317 100644 --- a/src/chef/routes.clj +++ b/src/chef/routes.clj @@ -7,41 +7,47 @@ [dotenv :as env] [clojure.string :as cstr] - [chef.pages.home :as cphome] - [chef.pages.admin :as cpadmin] - [chef.pages.recipe :as cprecipe] + [chef.frontend.admin.recipe-editor :as cfarecipe-editor] + [chef.frontend.admin :as cfadmin] - [chef.components.search :as ccsearch] + [chef.frontend.visitor.recipe :as cfvrecipe] + [chef.frontend.visitor.recipe.thumbnail :as cfvrthumbnail] + [chef.frontend.visitor.search :as cfvsearch] + [chef.frontend.visitor.home :as cfvhome] - [chef.pages.admin.api :as cpaapi] - [chef.pages.admin.recipe-editor :as cparecipe-editor])) + [chef.api.admin.category :as caacategory] + [chef.api.admin.recipe :as caarecipe])) -(def router [["/" {:get {:handler cphome/handler}}] - ["/recipes/:id" - ["/" {:get cprecipe/handler}] - ["/thumbnail" {:get cprecipe/thumbnail-handler}]] - ["/static/*" (rring/create-resource-handler)] - ["/admin" - ["/" {:get {:handler cpadmin/handler}}] - ["/recipe-editor/:id" {:get {:handler cparecipe-editor/handler}}]] +(def router [["/static/*" (rring/create-resource-handler)] + ;;; Visitor routes + ["/" {:get {:handler cfvhome/handler}}] + ["/recipes/:id" + ["/" {:get {:handler cfvrecipe/handler}}] + ["/thumbnail" {:get {:handler cfvrthumbnail/handler}}]] ["/components" - ["/search" {:get {:handler ccsearch/handler}}]] + ["/search" {:get {:handler cfvsearch/handler}}]] + ;;; Admin routes + ["/admin" + ["/" {:get {:handler cfadmin/handler}}] + ["/recipe-editor/:id" {:get {:handler cfarecipe-editor/handler}}]] + + ;;; API routes ["/api" ["/admin" - ["/create-category" {:post {:handler cpaapi/create-category}}] - ["/delete-category/:id" {:delete {:handler cpaapi/delete-category}}] - ["/edit-category/:id" {:post {:handler cpaapi/edit-category}}] - - ["/create-recipe" {:post {:handler cpaapi/create-recipe}}] - ["/delete-recipe/:id" {:delete {:handler cpaapi/delete-recipe}}] - ["/edit-recipe/:id" {:post {:handler cpaapi/edit-recipe}}] - - ["/delete-thumbnail/:id" {:delete {:handler cpaapi/delete-thumbnail}}]]]]) + ["/category" + ["/create" {:post {:handler caacategory/handle-create}}] + ["/:id" {:post {:handler caacategory/handle-edit} + :delete {:handler caacategory/handle-delete}}]] + ["/recipe" + ["/create" {:post {:handler caarecipe/handle-create}}] + ["/:id" {:post {:handler caarecipe/handle-edit} + :delete {:handler caarecipe/handle-delete}} + ["/thumbnail" {:delete {:handler caarecipe/handle-delete-thumbnail}}]]]]]]) (def ring-handler (delay (-> router - rring/router + (rring/router {:conflicts nil}) (rring/ring-handler (rring/redirect-trailing-slash-handler)) (rmoauth2/wrap-oauth2 {:auth {:authorize-uri (env/env "OAUTH_AUTH_URI") :access-token-uri (env/env "OAUTH_ACCESS_TOKEN_URI") diff --git a/src/chef/utils.clj b/src/chef/utils.clj index 22797cb..7329737 100644 --- a/src/chef/utils.clj +++ b/src/chef/utils.clj @@ -1,10 +1,5 @@ (ns chef.utils - (:require [chef.database :as cdb] - [honey.sql :as sql] - [next.jdbc :as jdbc] - [ring.util.response :as ruresp] - [clojure.string :as cstr]) - (:import java.io.File)) + (:require [ring.util.response :as ruresp])) (defn gen-page [title & content] [:html @@ -24,25 +19,9 @@ (ruresp/status 302) (ruresp/header "Location" "/auth")))) -(defn category-parents [category] - (loop [parents (list) - category category] - (let [updated-parents (conj parents category)] - (if (not= -1 (:categories/parent category)) - (recur updated-parents - (->> {:select [:*] - :from [:categories] - :where [:= :id (:categories/parent category)]} - sql/format - (jdbc/execute! @cdb/db) - first)) - updated-parents)))) - -(defn category-path [category] - (->> category - category-parents - (map #(:categories/name %)) - (cstr/join " > "))) +(defn s->int-or-nil [s] + (try (Integer/parseInt s) + (catch Exception _ nil))) (defn parse-ingredients [s] (->> s @@ -56,11 +35,3 @@ (->> s (re-matches #"(([A-z0-9 ]*)=([0-9]*) ?([A-z]*)\n?)*") some?))) - -(defn get-thumbnail-file [recipe] - (let [thumbnails-folder (File. "./thumbnails/")] - (->> thumbnails-folder - .listFiles - (filter #(cstr/starts-with? (.getName ^File %) - (str (:recipes/id recipe) "."))) - first))) -- cgit v1.2.3