From f7b571f670fd7f883b968a84b4dc2ed0ec2df66b Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Tue, 17 Feb 2026 14:50:42 +0100 Subject: [PATCH] Initial project setup: Clojure Kit web app for BTC price tracking Kit framework with Undertow, Reitit routing, Hiccup + HTMX templating, Tailwind CSS v4, PostgreSQL (empty database), and mobile-first index page. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 18 + CLAUDE.md | 55 + Dockerfile | 13 + Makefile | 19 + bb.edn | 13 + build.clj | 28 + deps.edn | 61 + .../clj/pmagnus/btcprice/dev_middleware.clj | 4 + env/dev/clj/pmagnus/btcprice/env.clj | 11 + env/dev/clj/user.clj | 48 + env/dev/resources/logback.xml | 15 + env/prod/clj/pmagnus/btcprice/env.clj | 10 + env/prod/resources/logback.xml | 14 + env/test/resources/logback.xml | 11 + kit.edn | 8 + modules/install-log.edn | 1 + package-lock.json | 1054 +++++++++++++++++ package.json | 13 + resources/css/input.css | 2 + resources/migrations/placeholder.txt | 1 + resources/public/css/output.css | 2 + resources/queries.sql | 2 + resources/system.edn | 60 + src/clj/pmagnus/btcprice/config.clj | 8 + src/clj/pmagnus/btcprice/core.clj | 36 + .../btcprice/web/controllers/health.clj | 11 + src/clj/pmagnus/btcprice/web/handler.clj | 31 + src/clj/pmagnus/btcprice/web/htmx.clj | 31 + .../pmagnus/btcprice/web/middleware/core.clj | 9 + .../btcprice/web/middleware/exception.clj | 21 + .../btcprice/web/middleware/formats.clj | 12 + src/clj/pmagnus/btcprice/web/routes/api.clj | 42 + src/clj/pmagnus/btcprice/web/routes/ui.clj | 38 + src/clj/pmagnus/btcprice/web/routes/utils.clj | 9 + tailwind.config.js | 5 + test/clj/pmagnus/btcprice/core_test.clj | 6 + test/clj/pmagnus/btcprice/test_utils.clj | 21 + .../clj/pmagnus/btcprice/web/request_test.clj | 13 + 38 files changed, 1756 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 bb.edn create mode 100644 build.clj create mode 100644 deps.edn create mode 100644 env/dev/clj/pmagnus/btcprice/dev_middleware.clj create mode 100644 env/dev/clj/pmagnus/btcprice/env.clj create mode 100644 env/dev/clj/user.clj create mode 100644 env/dev/resources/logback.xml create mode 100644 env/prod/clj/pmagnus/btcprice/env.clj create mode 100644 env/prod/resources/logback.xml create mode 100644 env/test/resources/logback.xml create mode 100644 kit.edn create mode 100644 modules/install-log.edn create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 resources/css/input.css create mode 100644 resources/migrations/placeholder.txt create mode 100644 resources/public/css/output.css create mode 100644 resources/queries.sql create mode 100644 resources/system.edn create mode 100644 src/clj/pmagnus/btcprice/config.clj create mode 100644 src/clj/pmagnus/btcprice/core.clj create mode 100644 src/clj/pmagnus/btcprice/web/controllers/health.clj create mode 100644 src/clj/pmagnus/btcprice/web/handler.clj create mode 100644 src/clj/pmagnus/btcprice/web/htmx.clj create mode 100644 src/clj/pmagnus/btcprice/web/middleware/core.clj create mode 100644 src/clj/pmagnus/btcprice/web/middleware/exception.clj create mode 100644 src/clj/pmagnus/btcprice/web/middleware/formats.clj create mode 100644 src/clj/pmagnus/btcprice/web/routes/api.clj create mode 100644 src/clj/pmagnus/btcprice/web/routes/ui.clj create mode 100644 src/clj/pmagnus/btcprice/web/routes/utils.clj create mode 100644 tailwind.config.js create mode 100644 test/clj/pmagnus/btcprice/core_test.clj create mode 100644 test/clj/pmagnus/btcprice/test_utils.clj create mode 100644 test/clj/pmagnus/btcprice/web/request_test.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4ffb68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/target +/classes +/checkouts +profiles.clj +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +/.cpcache +/.clj-kondo +/node_modules +/log +.DS_Store +*.log +/.env +/token diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..90dbe00 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# btcprice + +Bitcoin price tracker - Clojure Kit web application. + +## Build & Development Commands + +- `make run` — Start dev server on port 3000 +- `make repl` — Start nREPL on port 7888 +- `make test` — Run tests +- `make tailwind` — Watch Tailwind CSS for changes +- `make uberjar` — Build production JAR +- `docker compose up -d` — Start PostgreSQL + +## REPL Commands + +```clojure +(dev-prep!) ;; Prepare dev system +(go) ;; Start system +(reset) ;; Reload code & restart +(halt) ;; Stop system +(reset-db) ;; Drop & re-migrate database +(migrate) ;; Run pending migrations +(rollback) ;; Rollback last migration +(query-fn) ;; Get database query function +``` + +## Architecture + +- **Framework:** Kit (Integrant-based) +- **Server:** Undertow +- **Routing:** Reitit +- **Templates:** Hiccup + HTMX +- **Styling:** Tailwind CSS v4 +- **Database:** PostgreSQL + conman + Migratus + +## Source Layout + +``` +src/clj/pmagnus/btcprice/ +├── core.clj # App entry point +├── config.clj # System config loader +└── web/ + ├── handler.clj # Ring handler + routes + ├── htmx.clj # page/fragment macros + ├── controllers/ + │ └── health.clj # Health check + ├── middleware/ + │ ├── core.clj # Base middleware + │ ├── exception.clj # Exception handling + │ └── formats.clj # Content negotiation + └── routes/ + ├── api.clj # /api routes (JSON) + ├── ui.clj # UI routes (HTML) + └── utils.clj # Route utilities +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77461ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM clojure:openjdk-25 AS build + +WORKDIR /build +COPY . . +RUN clj -Sforce -T:build all + +FROM azul/zulu-openjdk-alpine:25 + +COPY --from=build /build/target/btcprice-standalone.jar /btcprice/btcprice-standalone.jar + +EXPOSE 4040 + +CMD ["java", "-jar", "/btcprice/btcprice-standalone.jar"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33e0bf2 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: clean run repl tailwind test uberjar + +clean: + rm -rf target + +run: + clj -M:dev -e "(dev-prep!) (go)" -r + +repl: + clj -M:dev:nrepl + +tailwind: + npm run css:watch + +test: + clj -M:test + +uberjar: + clj -T:build all diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..14dcd2e --- /dev/null +++ b/bb.edn @@ -0,0 +1,13 @@ +{:tasks + {:run {:doc "Start dev server" + :task (shell "clj -M:dev")} + :nrepl {:doc "Start nREPL" + :task (shell "clj -M:dev:nrepl")} + :cider {:doc "Start CIDER" + :task (shell "clj -M:dev:cider")} + :test {:doc "Run tests" + :task (shell "clj -M:test")} + :uberjar {:doc "Build uberjar" + :task (shell "clj -T:build all")} + :format {:doc "Format code" + :task (shell "clj -M:dev -m cljstyle.main fix src test")}}} diff --git a/build.clj b/build.clj new file mode 100644 index 0000000..a38ce0a --- /dev/null +++ b/build.clj @@ -0,0 +1,28 @@ +(ns build + (:require [clojure.tools.build.api :as b])) + +(def lib 'pmagnus/btcprice) +(def version "0.0.1-SNAPSHOT") +(def target-dir "target") +(def class-dir (str target-dir "/classes")) + +(def basis (delay (b/create-basis {:project "deps.edn"}))) +(def uber-file (str target-dir "/btcprice-standalone.jar")) + +(defn clean [_] + (b/delete {:path target-dir})) + +(defn uber [_] + (clean nil) + (b/copy-dir {:src-dirs ["src/clj" "resources" "env/prod/resources" "env/prod/clj"] + :target-dir class-dir}) + (b/compile-clj {:basis @basis + :ns-compile '[pmagnus.btcprice.core] + :class-dir class-dir}) + (b/uber {:class-dir class-dir + :uber-file uber-file + :basis @basis + :main 'pmagnus.btcprice.core})) + +(defn all [_] + (uber nil)) diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..e623494 --- /dev/null +++ b/deps.edn @@ -0,0 +1,61 @@ +{:paths ["src/clj" "resources"] + :deps {org.clojure/clojure {:mvn/version "1.12.3"} + org.clojure/data.json {:mvn/version "2.5.1"} + + ;; Kit + io.github.kit-clj/kit-core {:mvn/version "1.0.6"} + + ;; Logging + ch.qos.logback/logback-classic {:mvn/version "1.5.20"} + + ;; HTTP Server + io.github.kit-clj/kit-undertow {:mvn/version "1.0.10"} + + ;; Routing + metosin/reitit {:mvn/version "0.9.2"} + metosin/reitit-ring {:mvn/version "0.9.2"} + metosin/reitit-middleware {:mvn/version "0.9.2"} + metosin/reitit-swagger {:mvn/version "0.9.2"} + metosin/reitit-swagger-ui {:mvn/version "0.9.2"} + metosin/reitit-malli {:mvn/version "0.9.2"} + + ;; Web + ring/ring-core {:mvn/version "1.15.3"} + ring/ring-defaults {:mvn/version "0.7.0"} + metosin/ring-http-response {:mvn/version "0.9.5"} + hiccup/hiccup {:mvn/version "2.0.0"} + + ;; Serialization + metosin/muuntaja {:mvn/version "0.6.11"} + luminus-transit/luminus-transit {:mvn/version "0.1.6"} + + ;; Database + io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"} + io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"} + io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"}} + + :aliases + {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.9"}} + :ns-default build} + + :dev {:extra-paths ["env/dev/clj" "env/dev/resources" "test/clj"] + :extra-deps {integrant/repl {:mvn/version "0.4.0"} + criterium/criterium {:mvn/version "0.4.6"} + expound/expound {:mvn/version "0.9.0"} + com.lambdaisland/classpath {:mvn/version "0.4.44"} + ring/ring-devel {:mvn/version "1.15.3"}}} + + :test {:extra-paths ["env/test/resources"] + :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"} + peridot/peridot {:mvn/version "0.5.4"} + clj-commons/byte-streams {:mvn/version "0.3.4"}} + :main-opts ["-m" "cognitect.test-runner"] + :exec-fn cognitect.test-runner.api/test} + + :nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}} + :main-opts ["-m" "nrepl.cmdline" + "--middleware" "[integrant.repl/middleware]"]} + + :cider {:extra-deps {cider/cider-nrepl {:mvn/version "0.55.7"}} + :main-opts ["-m" "nrepl.cmdline" + "--middleware" "[cider.nrepl/cider-middleware]"]}}} diff --git a/env/dev/clj/pmagnus/btcprice/dev_middleware.clj b/env/dev/clj/pmagnus/btcprice/dev_middleware.clj new file mode 100644 index 0000000..95c2c32 --- /dev/null +++ b/env/dev/clj/pmagnus/btcprice/dev_middleware.clj @@ -0,0 +1,4 @@ +(ns pmagnus.btcprice.dev-middleware) + +(defn wrap-dev [handler _opts] + handler) diff --git a/env/dev/clj/pmagnus/btcprice/env.clj b/env/dev/clj/pmagnus/btcprice/env.clj new file mode 100644 index 0000000..6f3b7e0 --- /dev/null +++ b/env/dev/clj/pmagnus/btcprice/env.clj @@ -0,0 +1,11 @@ +(ns pmagnus.btcprice.env + (:require + [clojure.tools.logging :as log] + [pmagnus.btcprice.dev-middleware :refer [wrap-dev]])) + +(def defaults + {:init (fn [] (log/info "\n-=[btcprice starting...dev/test profile]=-")) + :start (fn [] (log/info "\n-=[btcprice started...dev/test profile]=-")) + :stop (fn [] (log/info "\n-=[btcprice has shut down]=-")) + :middleware wrap-dev + :opts {:profile :dev}}) diff --git a/env/dev/clj/user.clj b/env/dev/clj/user.clj new file mode 100644 index 0000000..7a08cb2 --- /dev/null +++ b/env/dev/clj/user.clj @@ -0,0 +1,48 @@ +(ns user + (:require + [clojure.tools.logging :as log] + [integrant.core :as ig] + [integrant.repl :refer [go halt reset reset-all set-prep!]] + [pmagnus.btcprice.config :as config])) + +(defn dev-prep! [] + (set-prep! + (fn [] + (-> (config/system-config {:profile :dev}) + (ig/prep))))) + +(defn test-prep! [] + (set-prep! + (fn [] + (-> (config/system-config {:profile :test}) + (ig/prep))))) + +(defn reset-db [] + (let [sys (or @pmagnus.btcprice.core/system + integrant.repl.state/system)] + (when-let [mig (:db.sql/migrations sys)] + (migratus.core/reset mig) + (log/info "Database reset complete")))) + +(defn rollback [] + (let [sys (or @pmagnus.btcprice.core/system + integrant.repl.state/system)] + (when-let [mig (:db.sql/migrations sys)] + (migratus.core/rollback mig)))) + +(defn migrate [] + (let [sys (or @pmagnus.btcprice.core/system + integrant.repl.state/system)] + (when-let [mig (:db.sql/migrations sys)] + (migratus.core/migrate mig)))) + +(defn query-fn [] + (let [sys (or @pmagnus.btcprice.core/system + integrant.repl.state/system)] + (:db.sql/query-fn sys))) + +(comment + (dev-prep!) + (go) + (reset) + (halt)) diff --git a/env/dev/resources/logback.xml b/env/dev/resources/logback.xml new file mode 100644 index 0000000..fbf1736 --- /dev/null +++ b/env/dev/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/env/prod/clj/pmagnus/btcprice/env.clj b/env/prod/clj/pmagnus/btcprice/env.clj new file mode 100644 index 0000000..11bb516 --- /dev/null +++ b/env/prod/clj/pmagnus/btcprice/env.clj @@ -0,0 +1,10 @@ +(ns pmagnus.btcprice.env + (:require + [clojure.tools.logging :as log])) + +(def defaults + {:init (fn [] (log/info "\n-=[btcprice starting]=-")) + :start (fn [] (log/info "\n-=[btcprice started successfully]=-")) + :stop (fn [] (log/info "\n-=[btcprice has shut down successfully]=-")) + :middleware (fn [handler _] handler) + :opts {:profile :prod}}) diff --git a/env/prod/resources/logback.xml b/env/prod/resources/logback.xml new file mode 100644 index 0000000..87d4bd2 --- /dev/null +++ b/env/prod/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/env/test/resources/logback.xml b/env/test/resources/logback.xml new file mode 100644 index 0000000..fb48b5e --- /dev/null +++ b/env/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/kit.edn b/kit.edn new file mode 100644 index 0000000..cd37390 --- /dev/null +++ b/kit.edn @@ -0,0 +1,8 @@ +{:full-name "pmagnus/btcprice" + :ns-name "pmagnus.btcprice" + :sanitized "pmagnus/btcprice" + :name "btcprice" + :modules {:root "modules" + :repositories [{:url "https://github.com/kit-clj/modules.git" + :tag "master" + :name "kit-modules"}]}} diff --git a/modules/install-log.edn b/modules/install-log.edn new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/modules/install-log.edn @@ -0,0 +1 @@ +{} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d8ab012 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1054 @@ +{ + "name": "btcprice", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "btcprice", + "version": "0.0.1", + "devDependencies": { + "@tailwindcss/cli": "^4.1.18", + "tailwindcss": "^4.1.18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.18" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a4cf8c1 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "btcprice", + "version": "0.0.1", + "private": true, + "scripts": { + "css:build": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --minify", + "css:watch": "npx @tailwindcss/cli -i resources/css/input.css -o resources/public/css/output.css --watch" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.18", + "tailwindcss": "^4.1.18" + } +} diff --git a/resources/css/input.css b/resources/css/input.css new file mode 100644 index 0000000..1009c3e --- /dev/null +++ b/resources/css/input.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@source "../../src/**/*.clj"; diff --git a/resources/migrations/placeholder.txt b/resources/migrations/placeholder.txt new file mode 100644 index 0000000..2586130 --- /dev/null +++ b/resources/migrations/placeholder.txt @@ -0,0 +1 @@ +Migration files go here. diff --git a/resources/public/css/output.css b/resources/public/css/output.css new file mode 100644 index 0000000..71c909a --- /dev/null +++ b/resources/public/css/output.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-gray-200:oklch(92.8% .006 264.531);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-lg:32rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--radius-2xl:1rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.max-w-lg{max-width:var(--container-lg)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}.rounded-2xl{border-radius:var(--radius-2xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-white{background-color:var(--color-white)}.p-6{padding:calc(var(--spacing)*6)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.text-center{text-align:center}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-900{color:var(--color-gray-900)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file diff --git a/resources/queries.sql b/resources/queries.sql new file mode 100644 index 0000000..710dc18 --- /dev/null +++ b/resources/queries.sql @@ -0,0 +1,2 @@ +-- queries for btcprice +-- Add HugSQL queries here as tables are created diff --git a/resources/system.edn b/resources/system.edn new file mode 100644 index 0000000..dfd5000 --- /dev/null +++ b/resources/system.edn @@ -0,0 +1,60 @@ +{:system/env #profile {:dev :dev :test :test :prod :prod} + + :server/http + {:port #long #or [#env PORT 4000] + :host #or [#env HTTP_HOST "0.0.0.0"] + :handler #ig/ref :handler/ring} + + :handler/ring + {:router #ig/ref :router/core + :api-path "/api" + :site-defaults-config + {:params {:keywordize true + :multipart true + :nested true} + :cookies true + :session {:flash true + :cookie-attrs {:http-only true + :same-site :strict}} + :security {:anti-forgery false + :xss-protection {:enable? true :mode :block} + :frame-options :sameorigin} + :static {:resources "public"} + :responses {:not-modified-responses true + :absolute-redirects true + :content-types true + :default-charset "utf-8"}}} + + :router/routes + {:routes #ig/refset :reitit/routes} + + :router/core + {:routes #ig/ref :router/routes + :env #ig/ref :system/env} + + :db.sql/connection + #profile {:dev {:jdbc-url "jdbc:postgresql://localhost:5432/btcprice?user=btcprice&password=btcprice"} + :test {:jdbc-url "jdbc:postgresql://localhost:5432/btcprice?user=btcprice&password=btcprice"} + :prod {:jdbc-url #env JDBC_URL + :init-size 1 + :min-idle 1 + :max-idle 8 + :max-active 32}} + + :db.sql/query-fn + {:conn #ig/ref :db.sql/connection + :options {} + :filename "queries.sql"} + + :db.sql/migrations + {:store :database + :db {:datasource #ig/ref :db.sql/connection} + :migrate-on-init? true} + + :reitit.routes/api + {:base-path "/api" + :query-fn #ig/ref :db.sql/query-fn} + + :reitit.routes/ui + {:base-path "" + :query-fn #ig/ref :db.sql/query-fn}} diff --git a/src/clj/pmagnus/btcprice/config.clj b/src/clj/pmagnus/btcprice/config.clj new file mode 100644 index 0000000..dd29a05 --- /dev/null +++ b/src/clj/pmagnus/btcprice/config.clj @@ -0,0 +1,8 @@ +(ns pmagnus.btcprice.config + (:require + [kit.config :as config])) + +(def ^:const system-filename "system.edn") + +(defn system-config [options] + (config/read-config system-filename options)) diff --git a/src/clj/pmagnus/btcprice/core.clj b/src/clj/pmagnus/btcprice/core.clj new file mode 100644 index 0000000..6c8c7a5 --- /dev/null +++ b/src/clj/pmagnus/btcprice/core.clj @@ -0,0 +1,36 @@ +(ns pmagnus.btcprice.core + (:require + [clojure.tools.logging :as log] + [integrant.core :as ig] + [pmagnus.btcprice.config :as config] + [pmagnus.btcprice.env :refer [defaults]] + + ;; Edges + [kit.edge.db.postgres] + [kit.edge.db.sql.conman] + [kit.edge.db.sql.migratus] + [kit.edge.server.undertow]) + (:gen-class)) + +(defonce system (atom nil)) + +(defn stop-app [] + ((or (:stop defaults) (fn []))) + (some-> (deref system) (ig/halt!)) + (shutdown-agents)) + +(defn start-app [& [params]] + ((or (:init defaults) (fn []))) + (->> (config/system-config (or params {})) + (ig/prep) + (ig/init) + (reset! system)) + ((or (:start defaults) (fn [])))) + +(defn -main [& _] + (Thread/setDefaultUncaughtExceptionHandler + (reify Thread$UncaughtExceptionHandler + (uncaughtException [_ _thread ex] + (log/error ex "Uncaught exception")))) + (start-app) + (.addShutdownHook (Runtime/getRuntime) (Thread. stop-app))) diff --git a/src/clj/pmagnus/btcprice/web/controllers/health.clj b/src/clj/pmagnus/btcprice/web/controllers/health.clj new file mode 100644 index 0000000..f612714 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/controllers/health.clj @@ -0,0 +1,11 @@ +(ns pmagnus.btcprice.web.controllers.health + (:require + [ring.util.http-response :as response]) + (:import + [java.lang.management ManagementFactory])) + +(defn healthcheck! [_req] + (response/ok + {:time (str (java.time.Instant/now)) + :up-time (.. ManagementFactory getRuntimeMXBean getUptime) + :app {:status "up"}})) diff --git a/src/clj/pmagnus/btcprice/web/handler.clj b/src/clj/pmagnus/btcprice/web/handler.clj new file mode 100644 index 0000000..b010ec3 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/handler.clj @@ -0,0 +1,31 @@ +(ns pmagnus.btcprice.web.handler + (:require + [integrant.core :as ig] + [reitit.ring :as ring] + [pmagnus.btcprice.web.middleware.core :as middleware])) + +(defmethod ig/init-key :handler/ring + [_ {:keys [router api-path] :as opts}] + (ring/ring-handler + (router) + (ring/routes + (ring/create-resource-handler {:path "/"}) + (when (some? api-path) + (reitit.ring/create-default-handler)) + (ring/create-default-handler + {:not-found (constantly {:status 404 :body "Page not found"}) + :method-not-allowed (constantly {:status 405 :body "Not allowed"}) + :not-acceptable (constantly {:status 406 :body "Not acceptable"})})) + {:middleware [(middleware/wrap-base opts)]})) + +(defmethod ig/init-key :router/routes + [_ {:keys [routes]}] + (mapv (fn [route] + (if (fn? route) (route) route)) + routes)) + +(defmethod ig/init-key :router/core + [_ {:keys [routes env]}] + (if (= env :dev) + (fn [] (ring/router routes)) + (constantly (ring/router routes)))) diff --git a/src/clj/pmagnus/btcprice/web/htmx.clj b/src/clj/pmagnus/btcprice/web/htmx.clj new file mode 100644 index 0000000..69f8976 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/htmx.clj @@ -0,0 +1,31 @@ +(ns pmagnus.btcprice.web.htmx + (:require + [hiccup2.core :as h])) + +(defmacro page + [opts & content] + `(let [opts# ~opts] + {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8"} + :body (str + (h/html + (hiccup2.core/raw "") + [:html {:lang (or (:lang opts#) "en")} + [:head + [:meta {:charset "utf-8"}] + [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] + [:meta {:name "apple-mobile-web-app-capable" :content "yes"}] + [:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}] + [:title (or (:title opts#) "BTC Price")] + [:link {:rel "stylesheet" :href "/css/output.css"}] + [:script {:src "https://unpkg.com/htmx.org@2.0.8"}]] + [:body + [:div.mx-auto.max-w-lg.px-4.py-6 + ~@content]]]))})) + +(defmacro fragment + [opts & content] + `(let [_opts# ~opts] + {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8"} + :body (str (h/html ~@content))})) diff --git a/src/clj/pmagnus/btcprice/web/middleware/core.clj b/src/clj/pmagnus/btcprice/web/middleware/core.clj new file mode 100644 index 0000000..25dd47d --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/middleware/core.clj @@ -0,0 +1,9 @@ +(ns pmagnus.btcprice.web.middleware.core + (:require + [ring.middleware.defaults :as defaults])) + +(defn wrap-base [{:keys [site-defaults-config]}] + (fn [handler] + (cond-> handler + true (defaults/wrap-defaults + (or site-defaults-config defaults/site-defaults))))) diff --git a/src/clj/pmagnus/btcprice/web/middleware/exception.clj b/src/clj/pmagnus/btcprice/web/middleware/exception.clj new file mode 100644 index 0000000..b266103 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/middleware/exception.clj @@ -0,0 +1,21 @@ +(ns pmagnus.btcprice.web.middleware.exception + (:require + [clojure.tools.logging :as log] + [reitit.ring.middleware.exception :as exception])) + +(defn- handler [message exception request] + (let [uri (:uri request)] + (log/error exception (str message " at " uri)) + {:status 500 + :body {:message message + :exception (.getClass exception) + :data (ex-data exception) + :uri uri}})) + +(def exception-middleware + (exception/create-exception-middleware + (merge + exception/default-handlers + {::exception/default (partial handler "Internal error") + ::exception/wrap (fn [handler e request] + (handler e request))}))) diff --git a/src/clj/pmagnus/btcprice/web/middleware/formats.clj b/src/clj/pmagnus/btcprice/web/middleware/formats.clj new file mode 100644 index 0000000..4fc5f58 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/middleware/formats.clj @@ -0,0 +1,12 @@ +(ns pmagnus.btcprice.web.middleware.formats + (:require + [luminus-transit.time :as time] + [muuntaja.core :as m])) + +(def instance + (m/create + (-> m/default-options + (update-in [:formats "application/transit+json" :decoder-opts] + (partial merge time/time-deserialization-handlers)) + (update-in [:formats "application/transit+json" :encoder-opts] + (partial merge time/time-serialization-handlers))))) diff --git a/src/clj/pmagnus/btcprice/web/routes/api.clj b/src/clj/pmagnus/btcprice/web/routes/api.clj new file mode 100644 index 0000000..be1d90b --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/routes/api.clj @@ -0,0 +1,42 @@ +(ns pmagnus.btcprice.web.routes.api + (:require + [integrant.core :as ig] + [pmagnus.btcprice.web.controllers.health :as health] + [pmagnus.btcprice.web.middleware.exception :as exception] + [pmagnus.btcprice.web.middleware.formats :as formats] + [reitit.coercion.malli :as malli] + [reitit.ring.coercion :as coercion] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.parameters :as parameters] + [reitit.swagger :as swagger] + [reitit.swagger-ui :as swagger-ui])) + +(defn- api-routes [_opts] + [["/swagger.json" + {:get {:no-doc true + :swagger {:info {:title "btcprice API"}} + :handler (swagger/create-swagger-handler)}}] + ["/health" + {:get health/healthcheck!}]]) + +(defn route-data [opts] + (merge + opts + {:coercion malli/coercion + :muuntaja formats/instance + :swagger {:id ::api} + :middleware [parameters/parameters-middleware + muuntaja/format-negotiate-middleware + muuntaja/format-response-middleware + exception/exception-middleware + muuntaja/format-request-middleware + coercion/coerce-request-middleware + coercion/coerce-response-middleware]})) + +(derive :reitit.routes/api :reitit/routes) + +(defmethod ig/init-key :reitit.routes/api + [_ {:keys [base-path] + :or {base-path ""} + :as opts}] + [base-path (route-data opts) (api-routes opts)]) diff --git a/src/clj/pmagnus/btcprice/web/routes/ui.clj b/src/clj/pmagnus/btcprice/web/routes/ui.clj new file mode 100644 index 0000000..a4bb894 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/routes/ui.clj @@ -0,0 +1,38 @@ +(ns pmagnus.btcprice.web.routes.ui + (:require + [integrant.core :as ig] + [pmagnus.btcprice.web.htmx :refer [page]] + [pmagnus.btcprice.web.middleware.exception :as exception] + [pmagnus.btcprice.web.middleware.formats :as formats] + [reitit.ring.middleware.muuntaja :as muuntaja] + [reitit.ring.middleware.parameters :as parameters])) + +(defn home-page [_req] + (page {:title "BTC Price"} + [:header.text-center.mb-8 + [:h1.text-3xl.font-bold.text-gray-900 "BTC Price"] + [:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]] + + [:div#price-panel.space-y-4 + [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 + [:p.text-center.text-gray-400.text-sm "Loading..."]]])) + +(defn- ui-routes [_opts] + [["/" + {:get home-page}]]) + +(defn route-data [opts] + (merge + opts + {:muuntaja formats/instance + :middleware [parameters/parameters-middleware + muuntaja/format-response-middleware + exception/exception-middleware]})) + +(derive :reitit.routes/ui :reitit/routes) + +(defmethod ig/init-key :reitit.routes/ui + [_ {:keys [base-path] + :or {base-path ""} + :as opts}] + [base-path (route-data opts) (ui-routes opts)]) diff --git a/src/clj/pmagnus/btcprice/web/routes/utils.clj b/src/clj/pmagnus/btcprice/web/routes/utils.clj new file mode 100644 index 0000000..8b12dc0 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/routes/utils.clj @@ -0,0 +1,9 @@ +(ns pmagnus.btcprice.web.routes.utils) + +(def route-data-path [:reitit.core/match :data]) + +(defn route-data [req] + (get-in req route-data-path)) + +(defn route-data-key [req k] + (get (route-data req) k)) diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..ceef7fe --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,5 @@ +module.exports = { + content: ["./src/**/*.clj", "./resources/**/*.html"], + theme: { extend: {} }, + plugins: [] +} diff --git a/test/clj/pmagnus/btcprice/core_test.clj b/test/clj/pmagnus/btcprice/core_test.clj new file mode 100644 index 0000000..f06537a --- /dev/null +++ b/test/clj/pmagnus/btcprice/core_test.clj @@ -0,0 +1,6 @@ +(ns pmagnus.btcprice.core-test + (:require + [clojure.test :refer [deftest is]])) + +(deftest app-loads-test + (is (= 1 1))) diff --git a/test/clj/pmagnus/btcprice/test_utils.clj b/test/clj/pmagnus/btcprice/test_utils.clj new file mode 100644 index 0000000..13992f3 --- /dev/null +++ b/test/clj/pmagnus/btcprice/test_utils.clj @@ -0,0 +1,21 @@ +(ns pmagnus.btcprice.test-utils + (:require + [integrant.core :as ig] + [integrant.repl.state :as state] + [pmagnus.btcprice.config :as config] + [pmagnus.btcprice.core :as core])) + +(defn system-state [] + (or @core/system state/system)) + +(defn system-fixture [] + (fn [f] + (let [system (-> (config/system-config {:profile :test}) + (ig/prep) + (ig/init))] + (reset! core/system system) + (try + (f) + (finally + (ig/halt! system) + (reset! core/system nil)))))) diff --git a/test/clj/pmagnus/btcprice/web/request_test.clj b/test/clj/pmagnus/btcprice/web/request_test.clj new file mode 100644 index 0000000..c3e0b27 --- /dev/null +++ b/test/clj/pmagnus/btcprice/web/request_test.clj @@ -0,0 +1,13 @@ +(ns pmagnus.btcprice.web.request-test + (:require + [clojure.test :refer [deftest is use-fixtures]] + [pmagnus.btcprice.test-utils :as tu])) + +(use-fixtures :once (tu/system-fixture)) + +(deftest health-request-test + (let [sys (tu/system-state) + app (get sys :handler/ring)] + (when app + (let [resp (app {:request-method :get :uri "/api/health"})] + (is (= 200 (:status resp)))))))