elm-ssr 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -342
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/package.json +53 -24
- package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
- package/src/sse.ts +162 -0
- package/AGENTS.md +0 -289
- package/CHANGELOG.md +0 -87
- package/LICENSE +0 -21
- package/bun.lock +0 -259
- package/docker-compose.yml +0 -33
- package/docs/README.md +0 -51
- package/docs/backends.md +0 -146
- package/docs/cli.md +0 -117
- package/docs/effects.md +0 -91
- package/docs/getting-started.md +0 -94
- package/docs/islands.md +0 -197
- package/docs/loaders-and-actions.md +0 -241
- package/docs/middleware.md +0 -93
- package/docs/migrations.md +0 -143
- package/docs/routing.md +0 -108
- package/docs/sessions.md +0 -218
- package/docs/tasks.md +0 -149
- package/docs/testing.md +0 -84
- package/elm-ssr.config.json +0 -14
- package/examples/basic/elm.json +0 -27
- package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
- package/examples/basic/migrations/0001_guestbook.sql +0 -10
- package/examples/basic/package.json +0 -10
- package/examples/basic/runtime.ts +0 -148
- package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
- package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
- package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
- package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
- package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
- package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
- package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
- package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
- package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
- package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
- package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
- package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
- package/examples/basic/styles.ts +0 -204
- package/examples/basic/worker.ts +0 -3
- package/examples/crypto-dashboard/elm.json +0 -30
- package/examples/crypto-dashboard/package.json +0 -10
- package/examples/crypto-dashboard/runtime.ts +0 -97
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
- package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
- package/examples/crypto-dashboard/styles.ts +0 -23
- package/examples/crypto-dashboard/worker.ts +0 -3
- package/llms.txt +0 -69
- package/packages/elm-ssr/README.md +0 -67
- package/packages/elm-ssr/package.json +0 -61
- package/scripts/benchmark.mjs +0 -60
- package/test/action.test.ts +0 -81
- package/test/adapters.test.ts +0 -173
- package/test/advanced-robustness.test.ts +0 -75
- package/test/app.test.ts +0 -209
- package/test/browser-island.test.ts +0 -184
- package/test/cli-migrate.test.ts +0 -97
- package/test/cli.test.ts +0 -94
- package/test/cookies.test.ts +0 -156
- package/test/crypto-dashboard.test.ts +0 -35
- package/test/effects.test.ts +0 -117
- package/test/http.test.ts +0 -50
- package/test/integration/redis-postgres.test.ts +0 -174
- package/test/island-runtime.test.ts +0 -214
- package/test/middleware.test.ts +0 -134
- package/test/migrations.test.ts +0 -244
- package/test/profile.test.ts +0 -159
- package/test/robustness.test.ts +0 -135
- package/test/serialize.test.ts +0 -92
- package/test/sessions.test.ts +0 -429
- package/test/svg.test.ts +0 -65
- package/tsconfig.json +0 -20
- package/wrangler.jsonc +0 -11
- /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
- /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
- /package/{packages/elm-ssr/src → src}/app.ts +0 -0
- /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
- /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/http.ts +0 -0
- /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
- /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
- /package/{packages/elm-ssr/src → src}/render.ts +0 -0
- /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
- /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
- /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
- /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { createWorkerApp } from "elm-ssr";
|
|
2
|
-
import { defaultEffectRunner, type EffectRunner } from "elm-ssr/effects";
|
|
3
|
-
import { renderApp, type CompiledElmModule } from "elm-ssr/render";
|
|
4
|
-
import type { RouteCatalog } from "elm-ssr/http";
|
|
5
|
-
import { islands, bundleSource } from "../../generated/examples/crypto-dashboard/islands-manifest";
|
|
6
|
-
import { stylesheet } from "./styles";
|
|
7
|
-
// @ts-expect-error Generated at build time.
|
|
8
|
-
import ElmRuntime from "../../generated/examples/crypto-dashboard/app.mjs";
|
|
9
|
-
|
|
10
|
-
const elmModule = ElmRuntime as CompiledElmModule;
|
|
11
|
-
|
|
12
|
-
export const routes: RouteCatalog = {
|
|
13
|
-
pages: [
|
|
14
|
-
{
|
|
15
|
-
path: "/",
|
|
16
|
-
methods: ["GET", "HEAD"],
|
|
17
|
-
description: "Crypto Dashboard landing page."
|
|
18
|
-
}
|
|
19
|
-
],
|
|
20
|
-
assets: [
|
|
21
|
-
{
|
|
22
|
-
path: "/styles.css",
|
|
23
|
-
methods: ["GET", "HEAD"],
|
|
24
|
-
description: "Tailwind base styles."
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
path: "/__elm-ssr/islands.js",
|
|
28
|
-
methods: ["GET", "HEAD"],
|
|
29
|
-
description: "Island loader runtime."
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
path: "/__elm-ssr/islands-bundle.js",
|
|
33
|
-
methods: ["GET", "HEAD"],
|
|
34
|
-
description: "Shared island bundle (Charts, etc)."
|
|
35
|
-
}
|
|
36
|
-
],
|
|
37
|
-
utility: [
|
|
38
|
-
{
|
|
39
|
-
path: "/health",
|
|
40
|
-
methods: ["GET", "HEAD"],
|
|
41
|
-
description: "Liveness endpoint."
|
|
42
|
-
}
|
|
43
|
-
],
|
|
44
|
-
api: [
|
|
45
|
-
{
|
|
46
|
-
path: "/api/health",
|
|
47
|
-
methods: ["GET", "HEAD"],
|
|
48
|
-
description: "JSON health payload."
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
path: "/api/render",
|
|
52
|
-
methods: ["GET", "HEAD"],
|
|
53
|
-
description: "SSR preview endpoint."
|
|
54
|
-
}
|
|
55
|
-
]
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
|
|
59
|
-
const [pathname, search = ""] = path.split("?");
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
method: request?.method ?? "GET",
|
|
63
|
-
path: pathname,
|
|
64
|
-
query: Object.fromEntries(new URLSearchParams(search)),
|
|
65
|
-
formData: formData ?? {}
|
|
66
|
-
};
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// Mock CoinGecko API for stable testing and fast builds
|
|
70
|
-
export const cryptoEffects: EffectRunner = async (effect, context) => {
|
|
71
|
-
if (effect.kind === "fetchJson" && typeof effect.payload.url === "string" && effect.payload.url.includes("api.coingecko.com")) {
|
|
72
|
-
return {
|
|
73
|
-
ok: true,
|
|
74
|
-
value: [
|
|
75
|
-
{ id: "bitcoin", symbol: "btc", name: "Bitcoin", current_price: 65000.0, price_change_percentage_24h: 2.5 },
|
|
76
|
-
{ id: "ethereum", symbol: "eth", name: "Ethereum", current_price: 3500.0, price_change_percentage_24h: -1.2 },
|
|
77
|
-
{ id: "cardano", symbol: "ada", name: "Cardano", current_price: 0.45, price_change_percentage_24h: 0.5 },
|
|
78
|
-
{ id: "solana", symbol: "sol", name: "Solana", current_price: 145.0, price_change_percentage_24h: 5.8 }
|
|
79
|
-
]
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return defaultEffectRunner(effect, context);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
export const renderPath = async (path: string) =>
|
|
87
|
-
renderApp(elmModule, createFlags({ path }), { effects: cryptoEffects });
|
|
88
|
-
|
|
89
|
-
export const worker = createWorkerApp({
|
|
90
|
-
elmModule,
|
|
91
|
-
islands,
|
|
92
|
-
islandsBundle: bundleSource,
|
|
93
|
-
stylesheet,
|
|
94
|
-
routes,
|
|
95
|
-
createFlags,
|
|
96
|
-
effects: cryptoEffects
|
|
97
|
-
});
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
module CryptoDashboard.Islands.MarketOverview exposing
|
|
2
|
-
( embed
|
|
3
|
-
, Flags, Model, Msg
|
|
4
|
-
, encodeFlags
|
|
5
|
-
, init, main, subscriptions, update, view
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
import Browser
|
|
9
|
-
import ElmSsr.Html as SsrHtml exposing (Node)
|
|
10
|
-
import ElmSsr.Html.Attributes as SsrAttributes
|
|
11
|
-
import ElmSsr.Island as Island
|
|
12
|
-
import ElmSsr.Island.Shared as SharedBus
|
|
13
|
-
import Html exposing (Html, div, h3, p, span, text)
|
|
14
|
-
import Html.Attributes as HtmlAttr exposing (class)
|
|
15
|
-
import Html.Events exposing (onClick)
|
|
16
|
-
import Http
|
|
17
|
-
import Json.Decode as Decode
|
|
18
|
-
import Json.Encode as Encode
|
|
19
|
-
import Time
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
type alias Coin =
|
|
23
|
-
{ id : String
|
|
24
|
-
, symbol : String
|
|
25
|
-
, name : String
|
|
26
|
-
, price : Float
|
|
27
|
-
, change24h : Float
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
type alias Flags =
|
|
32
|
-
{ coins : List Coin }
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
type alias Model =
|
|
36
|
-
{ coins : List Coin
|
|
37
|
-
, selectedId : String
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
type Msg
|
|
42
|
-
= SelectCoin String
|
|
43
|
-
| Refresh
|
|
44
|
-
| GotCoins (Result Http.Error (List Coin))
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
embed : Flags -> SsrHtml.Node msg
|
|
48
|
-
embed =
|
|
49
|
-
Island.embed "MarketOverview"
|
|
50
|
-
{ encodeFlags = encodeFlags
|
|
51
|
-
, fallback = fallback
|
|
52
|
-
, id = Just "global-market-overview"
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
fallback : Flags -> List (SsrHtml.Node msg)
|
|
57
|
-
fallback flags =
|
|
58
|
-
[ SsrHtml.div [ SsrAttributes.class "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" ]
|
|
59
|
-
(List.map
|
|
60
|
-
(\coin ->
|
|
61
|
-
SsrHtml.div [ SsrAttributes.class "bg-slate-900 rounded-xl border border-slate-800 p-5 shadow-sm opacity-50" ]
|
|
62
|
-
[ SsrHtml.h3 [ SsrAttributes.class "font-bold text-slate-400 text-xs uppercase tracking-wider" ] [ SsrHtml.text coin.name ] ]
|
|
63
|
-
)
|
|
64
|
-
flags.coins
|
|
65
|
-
)
|
|
66
|
-
]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
encodeFlags : Flags -> Encode.Value
|
|
70
|
-
encodeFlags flags =
|
|
71
|
-
Encode.object
|
|
72
|
-
[ ( "coins"
|
|
73
|
-
, Encode.list
|
|
74
|
-
(\c ->
|
|
75
|
-
Encode.object
|
|
76
|
-
[ ( "id", Encode.string c.id )
|
|
77
|
-
, ( "symbol", Encode.string c.symbol )
|
|
78
|
-
, ( "name", Encode.string c.name )
|
|
79
|
-
, ( "price", Encode.float c.price )
|
|
80
|
-
, ( "change24h", Encode.float c.change24h )
|
|
81
|
-
]
|
|
82
|
-
)
|
|
83
|
-
flags.coins
|
|
84
|
-
)
|
|
85
|
-
]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
main : Program Flags Model Msg
|
|
89
|
-
main =
|
|
90
|
-
Browser.element
|
|
91
|
-
{ init = init
|
|
92
|
-
, update = update
|
|
93
|
-
, view = view
|
|
94
|
-
, subscriptions = subscriptions
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
init : Flags -> ( Model, Cmd Msg )
|
|
99
|
-
init flags =
|
|
100
|
-
-- Flags carry the server-rendered snapshot; refresh live from the client.
|
|
101
|
-
( { coins = flags.coins, selectedId = "bitcoin" }, fetchMarkets )
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
105
|
-
update msg model =
|
|
106
|
-
case msg of
|
|
107
|
-
SelectCoin id ->
|
|
108
|
-
( { model | selectedId = id }
|
|
109
|
-
, SharedBus.broadcast "coin-selected" (Encode.string id)
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
Refresh ->
|
|
113
|
-
( model, fetchMarkets )
|
|
114
|
-
|
|
115
|
-
GotCoins (Ok coins) ->
|
|
116
|
-
( { model | coins = coins }, Cmd.none )
|
|
117
|
-
|
|
118
|
-
GotCoins (Err _) ->
|
|
119
|
-
( model, Cmd.none )
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
subscriptions : Model -> Sub Msg
|
|
123
|
-
subscriptions _ =
|
|
124
|
-
Time.every 15000 (\_ -> Refresh)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
fetchMarkets : Cmd Msg
|
|
128
|
-
fetchMarkets =
|
|
129
|
-
Http.get
|
|
130
|
-
{ url = marketsUrl
|
|
131
|
-
, expect = Http.expectJson GotCoins marketDecoder
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
marketsUrl : String
|
|
136
|
-
marketsUrl =
|
|
137
|
-
"https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=bitcoin,ethereum,cardano,solana&order=market_cap_desc"
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
marketDecoder : Decode.Decoder (List Coin)
|
|
141
|
-
marketDecoder =
|
|
142
|
-
Decode.list
|
|
143
|
-
(Decode.map5 Coin
|
|
144
|
-
(Decode.field "id" Decode.string)
|
|
145
|
-
(Decode.field "symbol" Decode.string)
|
|
146
|
-
(Decode.field "name" Decode.string)
|
|
147
|
-
(Decode.field "current_price" Decode.float)
|
|
148
|
-
(Decode.field "price_change_percentage_24h" Decode.float)
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
view : Model -> Html Msg
|
|
153
|
-
view model =
|
|
154
|
-
div [ class "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" ]
|
|
155
|
-
(List.map (coinCard model.selectedId) model.coins)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
coinCard : String -> Coin -> Html Msg
|
|
159
|
-
coinCard selectedId coin =
|
|
160
|
-
let
|
|
161
|
-
isSelected =
|
|
162
|
-
selectedId == coin.id
|
|
163
|
-
in
|
|
164
|
-
div
|
|
165
|
-
[ class
|
|
166
|
-
("bg-slate-900 rounded-xl border p-5 shadow-sm transition-all group cursor-pointer "
|
|
167
|
-
++ (if isSelected then
|
|
168
|
-
"border-indigo-500 ring-1 ring-indigo-500/50 shadow-indigo-500/20 shadow-lg"
|
|
169
|
-
|
|
170
|
-
else
|
|
171
|
-
"border-slate-800 hover:border-slate-700"
|
|
172
|
-
)
|
|
173
|
-
)
|
|
174
|
-
, onClick (SelectCoin coin.id)
|
|
175
|
-
]
|
|
176
|
-
[ div [ class "flex justify-between items-start mb-4" ]
|
|
177
|
-
[ div []
|
|
178
|
-
[ h3 [ class "font-bold text-slate-400 text-xs uppercase tracking-wider" ] [ text coin.name ]
|
|
179
|
-
, p [ class ("text-2xl font-bold transition-colors " ++ (if isSelected then "text-indigo-400" else "text-white group-hover:text-indigo-400")) ] [ text ("$" ++ String.fromFloat coin.price) ]
|
|
180
|
-
]
|
|
181
|
-
, span
|
|
182
|
-
[ class
|
|
183
|
-
(if coin.change24h >= 0 then
|
|
184
|
-
"text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded text-xs font-bold"
|
|
185
|
-
|
|
186
|
-
else
|
|
187
|
-
"text-rose-400 bg-rose-400/10 px-2 py-1 rounded text-xs font-bold"
|
|
188
|
-
)
|
|
189
|
-
]
|
|
190
|
-
[ text (String.fromFloat (roundTo 2 coin.change24h) ++ "%") ]
|
|
191
|
-
]
|
|
192
|
-
, div [ class "h-1 w-full bg-slate-800 rounded-full overflow-hidden" ]
|
|
193
|
-
[ div [ class "h-full bg-indigo-500 rounded-full", HtmlAttr.style "width" (String.fromFloat (clamp 0 100 (50 + coin.change24h)) ++ "%") ] []
|
|
194
|
-
]
|
|
195
|
-
]
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
roundTo : Int -> Float -> Float
|
|
199
|
-
roundTo decimalPlaces num =
|
|
200
|
-
let
|
|
201
|
-
factor =
|
|
202
|
-
10 ^ decimalPlaces |> toFloat
|
|
203
|
-
in
|
|
204
|
-
(num * factor |> round |> toFloat) / factor
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
module CryptoDashboard.Islands.PriceChart exposing
|
|
2
|
-
( embed
|
|
3
|
-
, Flags, Model, Msg
|
|
4
|
-
, encodeFlags
|
|
5
|
-
, init, main, subscriptions, update, view
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
import Browser
|
|
9
|
-
import ElmSsr.Html as SsrHtml exposing (Node)
|
|
10
|
-
import ElmSsr.Html.Attributes as SsrAttributes
|
|
11
|
-
import ElmSsr.Island as Island
|
|
12
|
-
import ElmSsr.Island.Shared as SharedBus
|
|
13
|
-
import Html exposing (Html, div, text)
|
|
14
|
-
import Html.Attributes as HtmlAttr
|
|
15
|
-
import Http
|
|
16
|
-
import Json.Decode as Decode
|
|
17
|
-
import Json.Encode as Encode
|
|
18
|
-
import Svg exposing (Svg, svg)
|
|
19
|
-
import Svg.Attributes as SvgAttr
|
|
20
|
-
import Time
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
embed : Flags -> SsrHtml.Node msg
|
|
24
|
-
embed =
|
|
25
|
-
Island.embed "PriceChart"
|
|
26
|
-
{ encodeFlags = encodeFlags
|
|
27
|
-
, fallback = fallback
|
|
28
|
-
, id = Just "btc-price-chart"
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
type alias Flags =
|
|
33
|
-
{ coinId : String }
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
type alias DataPoint =
|
|
37
|
-
{ time : Float
|
|
38
|
-
, price : Float
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
type Model
|
|
43
|
-
= Loading String
|
|
44
|
-
| Loaded String (List DataPoint)
|
|
45
|
-
| Error String String
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
type Msg
|
|
49
|
-
= GotData String (Result Http.Error (List DataPoint))
|
|
50
|
-
| OnGlobalEvent SharedBus.GlobalEvent
|
|
51
|
-
| Refresh
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
encodeFlags : Flags -> Encode.Value
|
|
55
|
-
encodeFlags flags =
|
|
56
|
-
Encode.object [ ( "coinId", Encode.string flags.coinId ) ]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fallback : Flags -> List (SsrHtml.Node msg)
|
|
60
|
-
fallback _ =
|
|
61
|
-
[ SsrHtml.div [ SsrAttributes.class "h-64 flex items-center justify-center bg-slate-900/50 rounded-lg animate-pulse" ]
|
|
62
|
-
[ SsrHtml.text "Loading market data..." ]
|
|
63
|
-
]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
main : Program Flags Model Msg
|
|
67
|
-
main =
|
|
68
|
-
Browser.element
|
|
69
|
-
{ init = init
|
|
70
|
-
, update = update
|
|
71
|
-
, view = view
|
|
72
|
-
, subscriptions = subscriptions
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
init : Flags -> ( Model, Cmd Msg )
|
|
77
|
-
init flags =
|
|
78
|
-
( Loading flags.coinId, fetchData flags.coinId )
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
fetchData : String -> Cmd Msg
|
|
82
|
-
fetchData coinId =
|
|
83
|
-
Http.get
|
|
84
|
-
{ url = "https://api.coingecko.com/api/v3/coins/" ++ coinId ++ "/market_chart?vs_currency=usd&days=7"
|
|
85
|
-
, expect = Http.expectJson (GotData coinId) chartDecoder
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
chartDecoder : Decode.Decoder (List DataPoint)
|
|
90
|
-
chartDecoder =
|
|
91
|
-
Decode.field "prices"
|
|
92
|
-
(Decode.list
|
|
93
|
-
(Decode.map2 DataPoint
|
|
94
|
-
(Decode.index 0 Decode.float)
|
|
95
|
-
(Decode.index 1 Decode.float)
|
|
96
|
-
)
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
101
|
-
update msg model =
|
|
102
|
-
case msg of
|
|
103
|
-
GotData coinId (Ok data) ->
|
|
104
|
-
( Loaded coinId data, Cmd.none )
|
|
105
|
-
|
|
106
|
-
GotData coinId (Err _) ->
|
|
107
|
-
( Error coinId "Failed to load chart", Cmd.none )
|
|
108
|
-
|
|
109
|
-
OnGlobalEvent event ->
|
|
110
|
-
if event.tag == "coin-selected" then
|
|
111
|
-
case Decode.decodeValue Decode.string event.payload of
|
|
112
|
-
Ok newCoinId ->
|
|
113
|
-
( Loading newCoinId, fetchData newCoinId )
|
|
114
|
-
|
|
115
|
-
Err _ ->
|
|
116
|
-
( model, Cmd.none )
|
|
117
|
-
|
|
118
|
-
else
|
|
119
|
-
( model, Cmd.none )
|
|
120
|
-
|
|
121
|
-
Refresh ->
|
|
122
|
-
-- Silently refetch the current coin (keep showing the last chart).
|
|
123
|
-
( model, fetchData (currentCoin model) )
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
currentCoin : Model -> String
|
|
127
|
-
currentCoin model =
|
|
128
|
-
case model of
|
|
129
|
-
Loading coinId ->
|
|
130
|
-
coinId
|
|
131
|
-
|
|
132
|
-
Loaded coinId _ ->
|
|
133
|
-
coinId
|
|
134
|
-
|
|
135
|
-
Error coinId _ ->
|
|
136
|
-
coinId
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
subscriptions : Model -> Sub Msg
|
|
140
|
-
subscriptions _ =
|
|
141
|
-
Sub.batch
|
|
142
|
-
[ SharedBus.listen OnGlobalEvent
|
|
143
|
-
, Time.every 15000 (\_ -> Refresh)
|
|
144
|
-
]
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
view : Model -> Html Msg
|
|
148
|
-
view model =
|
|
149
|
-
div [ HtmlAttr.class "w-full" ]
|
|
150
|
-
[ case model of
|
|
151
|
-
Loading id ->
|
|
152
|
-
div [ HtmlAttr.class "h-64 flex items-center justify-center text-slate-500 italic" ] [ text ("Fetching " ++ id ++ " history...") ]
|
|
153
|
-
|
|
154
|
-
Loaded _ data ->
|
|
155
|
-
renderChart data
|
|
156
|
-
|
|
157
|
-
Error _ msg ->
|
|
158
|
-
div [ HtmlAttr.class "h-64 flex items-center justify-center text-rose-500" ] [ text msg ]
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
renderChart : List DataPoint -> Html Msg
|
|
163
|
-
renderChart data =
|
|
164
|
-
let
|
|
165
|
-
w = 800
|
|
166
|
-
h = 250
|
|
167
|
-
padding = 30
|
|
168
|
-
|
|
169
|
-
prices = List.map .price data
|
|
170
|
-
minPrice = List.minimum prices |> Maybe.withDefault 0
|
|
171
|
-
maxPrice = List.maximum prices |> Maybe.withDefault 1
|
|
172
|
-
priceDiff = maxPrice - minPrice
|
|
173
|
-
|
|
174
|
-
times = List.map .time data
|
|
175
|
-
minTime = List.minimum times |> Maybe.withDefault 0
|
|
176
|
-
maxTime = List.maximum times |> Maybe.withDefault 1
|
|
177
|
-
timeDiff = maxTime - minTime
|
|
178
|
-
|
|
179
|
-
toX t = padding + ((t - minTime) / (if timeDiff == 0 then 1 else timeDiff) * (w - 2 * padding))
|
|
180
|
-
toY p = (h - padding) - ((p - minPrice) / (if priceDiff == 0 then 1 else priceDiff) * (h - 2 * padding))
|
|
181
|
-
|
|
182
|
-
points =
|
|
183
|
-
data
|
|
184
|
-
|> List.map (\d -> String.fromFloat (toX d.time) ++ "," ++ String.fromFloat (toY d.price))
|
|
185
|
-
|> String.join " "
|
|
186
|
-
in
|
|
187
|
-
svg
|
|
188
|
-
[ SvgAttr.viewBox ("0 0 " ++ String.fromFloat w ++ " " ++ String.fromFloat h)
|
|
189
|
-
, SvgAttr.class "w-full overflow-visible"
|
|
190
|
-
]
|
|
191
|
-
[ Svg.polyline
|
|
192
|
-
[ SvgAttr.points points
|
|
193
|
-
, SvgAttr.fill "none"
|
|
194
|
-
, SvgAttr.stroke "#6366f1"
|
|
195
|
-
, SvgAttr.strokeWidth "3"
|
|
196
|
-
, SvgAttr.strokeLinecap "round"
|
|
197
|
-
, SvgAttr.strokeLinejoin "round"
|
|
198
|
-
]
|
|
199
|
-
[]
|
|
200
|
-
]
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
module CryptoDashboard.Routes.Index exposing (page, action)
|
|
2
|
-
|
|
3
|
-
import CryptoDashboard.Islands.MarketOverview as MarketOverview
|
|
4
|
-
import CryptoDashboard.Islands.PriceChart as PriceChart
|
|
5
|
-
import CryptoDashboard.View.Shared as Shared
|
|
6
|
-
import ElmSsr.Action as Action exposing (Action)
|
|
7
|
-
import ElmSsr.Document exposing (Document)
|
|
8
|
-
import ElmSsr.Html exposing (Node, div, h2, section, text)
|
|
9
|
-
import ElmSsr.Html.Attributes exposing (class)
|
|
10
|
-
import ElmSsr.Loader as Loader exposing (Loader)
|
|
11
|
-
import ElmSsr.Page as Page
|
|
12
|
-
import ElmSsr.Route exposing (Request)
|
|
13
|
-
import Json.Decode as Decode
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
type alias Coin =
|
|
17
|
-
{ id : String
|
|
18
|
-
, symbol : String
|
|
19
|
-
, name : String
|
|
20
|
-
, price : Float
|
|
21
|
-
, change24h : Float
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
type alias MarketData =
|
|
26
|
-
{ coins : List Coin
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
page : Request -> Loader (Document Never)
|
|
31
|
-
page _ =
|
|
32
|
-
Loader.fetchJson { url = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=bitcoin,ethereum,cardano,solana&order=market_cap_desc", decoder = coinsDecoder }
|
|
33
|
-
|> Loader.map (\coins -> view { coins = coins })
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
action : Request -> Action (Document Never)
|
|
37
|
-
action _ =
|
|
38
|
-
Action.fail 405 "Method not allowed"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
coinsDecoder : Decode.Decoder (List Coin)
|
|
42
|
-
coinsDecoder =
|
|
43
|
-
Decode.list
|
|
44
|
-
(Decode.map5 Coin
|
|
45
|
-
(Decode.field "id" Decode.string)
|
|
46
|
-
(Decode.field "symbol" Decode.string)
|
|
47
|
-
(Decode.field "name" Decode.string)
|
|
48
|
-
(Decode.field "current_price" Decode.float)
|
|
49
|
-
(Decode.field "price_change_percentage_24h" Decode.float)
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
view : MarketData -> Document Never
|
|
54
|
-
view data =
|
|
55
|
-
Page.page
|
|
56
|
-
{ title = "Crypto Dashboard | CryptoPulse"
|
|
57
|
-
, head = Shared.head
|
|
58
|
-
, body =
|
|
59
|
-
[ Shared.shell "Market Overview"
|
|
60
|
-
[ MarketOverview.embed { coins = data.coins }
|
|
61
|
-
, section [ class "mt-12 bg-slate-900 rounded-2xl border border-slate-800 p-6 shadow-xl" ]
|
|
62
|
-
[ h2 [ class "text-xl font-bold text-white mb-6" ] [ text "Real-time Price Trend" ]
|
|
63
|
-
, PriceChart.embed { coinId = "bitcoin" }
|
|
64
|
-
]
|
|
65
|
-
]
|
|
66
|
-
]
|
|
67
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
module CryptoDashboard.Routes.NotFound exposing (page, action)
|
|
2
|
-
|
|
3
|
-
-- File-based routing: NotFound is the fallback when no other route matches.
|
|
4
|
-
|
|
5
|
-
import ElmSsr.Action as Action exposing (Action)
|
|
6
|
-
import ElmSsr.Document exposing (Document)
|
|
7
|
-
import ElmSsr.Html exposing (p, text)
|
|
8
|
-
import ElmSsr.Loader as Loader exposing (Loader)
|
|
9
|
-
import ElmSsr.Page as Page
|
|
10
|
-
import ElmSsr.Route exposing (Request)
|
|
11
|
-
import CryptoDashboard.View.Shared as Shared
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
page : Request -> Loader (Document Never)
|
|
15
|
-
page _ =
|
|
16
|
-
Loader.succeed view
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
action : Request -> Action (Document Never)
|
|
20
|
-
action _ =
|
|
21
|
-
Action.fail 405 "Method not allowed"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
view : Document Never
|
|
25
|
-
view =
|
|
26
|
-
Page.notFound
|
|
27
|
-
{ title = "Not Found | elm-ssr"
|
|
28
|
-
, head = Shared.head
|
|
29
|
-
, body = [ Shared.shell "404" [ p [] [ text "This route does not exist." ] ] ]
|
|
30
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
module CryptoDashboard.View.Shared exposing (head, shell)
|
|
2
|
-
|
|
3
|
-
import ElmSsr.Html as Html exposing (Node, a, div, h1, header, main_, nav, p, section, span, text)
|
|
4
|
-
import ElmSsr.Html.Attributes as SsrAttributes exposing (class, href)
|
|
5
|
-
import ElmSsr.Page as Page
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
head : List (Node msg)
|
|
9
|
-
head =
|
|
10
|
-
[ Page.metaCharset "utf-8"
|
|
11
|
-
, Page.metaViewport "width=device-width, initial-scale=1"
|
|
12
|
-
, Html.script [ class "tailwind-cdn", SsrAttributes.src "https://cdn.tailwindcss.com" ] []
|
|
13
|
-
, Page.stylesheet "/styles.css"
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
shell : String -> List (Node msg) -> Node msg
|
|
18
|
-
shell heading body =
|
|
19
|
-
div [ class "min-h-screen bg-slate-950 text-slate-100 font-sans" ]
|
|
20
|
-
[ nav [ class "border-b border-slate-800 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50" ]
|
|
21
|
-
[ div [ class "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between" ]
|
|
22
|
-
[ div [ class "flex items-center gap-2" ]
|
|
23
|
-
[ div [ class "w-8 h-8 bg-indigo-500 rounded-lg flex items-center justify-center font-bold text-white shadow-lg shadow-indigo-500/20" ] [ text "Σ" ]
|
|
24
|
-
, span [ class "font-bold text-xl tracking-tight" ] [ text "CryptoPulse" ]
|
|
25
|
-
]
|
|
26
|
-
, div [ class "flex gap-4 text-sm font-medium text-slate-400" ]
|
|
27
|
-
[ a [ class "hover:text-white transition-colors", href "/" ] [ text "Dashboard" ]
|
|
28
|
-
, a [ class "hover:text-white transition-colors", href "/market" ] [ text "Markets" ]
|
|
29
|
-
]
|
|
30
|
-
]
|
|
31
|
-
]
|
|
32
|
-
, main_ [ class "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" ]
|
|
33
|
-
(header [ class "mb-8" ]
|
|
34
|
-
[ h1 [ class "text-3xl font-bold text-white tracking-tight" ] [ text heading ]
|
|
35
|
-
, p [ class "text-slate-400 mt-1" ] [ text "Real-time market insights powered by Elm SSR" ]
|
|
36
|
-
]
|
|
37
|
-
:: body
|
|
38
|
-
)
|
|
39
|
-
]
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export const stylesheet = `
|
|
2
|
-
/* Tailwind is loaded via CDN in the head, so we only need base resets or custom utilities here. */
|
|
3
|
-
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;700&display=swap');
|
|
4
|
-
|
|
5
|
-
:root {
|
|
6
|
-
color-scheme: dark;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
body {
|
|
10
|
-
margin: 0;
|
|
11
|
-
background-color: #020617; /* bg-slate-950 */
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/* Custom chart animations */
|
|
15
|
-
.animate-pulse {
|
|
16
|
-
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
@keyframes pulse {
|
|
20
|
-
0%, 100% { opacity: 1; }
|
|
21
|
-
50% { opacity: .5; }
|
|
22
|
-
}
|
|
23
|
-
`;
|