mirror of
https://github.com/dillonkearns/elm-pages-v3-beta.git
synced 2024-12-23 11:55:41 +03:00
Add starting point for todos app example.
This commit is contained in:
parent
62719e97b2
commit
ea03083bf2
8
examples/todos/.gitignore
vendored
Normal file
8
examples/todos/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
elm-stuff/
|
||||
dist/
|
||||
.cache/
|
||||
.elm-pages/
|
||||
functions/render/
|
||||
functions/server-render/
|
||||
gen/
|
2
examples/todos/.nvmrc
Normal file
2
examples/todos/.nvmrc
Normal file
@ -0,0 +1,2 @@
|
||||
v17.2.0
|
||||
|
1
examples/todos/README.md
Normal file
1
examples/todos/README.md
Normal file
@ -0,0 +1 @@
|
||||
# README
|
316
examples/todos/adapter.mjs
Normal file
316
examples/todos/adapter.mjs
Normal file
@ -0,0 +1,316 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default async function run({
|
||||
renderFunctionFilePath,
|
||||
routePatterns,
|
||||
apiRoutePatterns,
|
||||
portsFilePath,
|
||||
htmlTemplate,
|
||||
}) {
|
||||
console.log("Running adapter script");
|
||||
ensureDirSync("functions/render");
|
||||
ensureDirSync("functions/server-render");
|
||||
|
||||
fs.copyFileSync(
|
||||
renderFunctionFilePath,
|
||||
"./functions/render/elm-pages-cli.js"
|
||||
);
|
||||
fs.copyFileSync(
|
||||
renderFunctionFilePath,
|
||||
"./functions/server-render/elm-pages-cli.js"
|
||||
);
|
||||
fs.copyFileSync(portsFilePath, "./functions/render/port-data-source.js");
|
||||
fs.copyFileSync(
|
||||
portsFilePath,
|
||||
"./functions/server-render/port-data-source.js"
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
"./functions/render/index.js",
|
||||
rendererCode(true, htmlTemplate)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
"./functions/server-render/index.js",
|
||||
rendererCode(false, htmlTemplate)
|
||||
);
|
||||
// TODO rename functions/render to functions/fallback-render
|
||||
// TODO prepend instead of writing file
|
||||
|
||||
const apiServerRoutes = apiRoutePatterns.filter(isServerSide);
|
||||
|
||||
ensureValidRoutePatternsForNetlify(apiServerRoutes);
|
||||
|
||||
// TODO filter apiRoutePatterns on is server side
|
||||
// TODO need information on whether api route is odb or serverless
|
||||
const apiRouteRedirects = apiServerRoutes
|
||||
.map((apiRoute) => {
|
||||
if (apiRoute.kind === "prerender-with-fallback") {
|
||||
return `${apiPatternToRedirectPattern(
|
||||
apiRoute.pathPattern
|
||||
)} /.netlify/builders/render 200`;
|
||||
} else if (apiRoute.kind === "serverless") {
|
||||
return `${apiPatternToRedirectPattern(
|
||||
apiRoute.pathPattern
|
||||
)} /.netlify/functions/server-render 200`;
|
||||
} else {
|
||||
throw "Unhandled 2";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const redirectsFile =
|
||||
routePatterns
|
||||
.filter(isServerSide)
|
||||
.map((route) => {
|
||||
if (route.kind === "prerender-with-fallback") {
|
||||
return `${route.pathPattern} /.netlify/builders/render 200
|
||||
${route.pathPattern}/content.dat /.netlify/builders/render 200`;
|
||||
} else {
|
||||
return `${route.pathPattern} /.netlify/functions/server-render 200
|
||||
${path.join(
|
||||
route.pathPattern,
|
||||
"/content.dat"
|
||||
)} /.netlify/functions/server-render 200`;
|
||||
}
|
||||
})
|
||||
.join("\n") +
|
||||
"\n" +
|
||||
apiRouteRedirects +
|
||||
"\n";
|
||||
|
||||
fs.writeFileSync("dist/_redirects", redirectsFile);
|
||||
}
|
||||
|
||||
function ensureValidRoutePatternsForNetlify(apiRoutePatterns) {
|
||||
const invalidNetlifyRoutes = apiRoutePatterns.filter((apiRoute) =>
|
||||
apiRoute.pathPattern.some(({ kind }) => kind === "hybrid")
|
||||
);
|
||||
if (invalidNetlifyRoutes.length > 0) {
|
||||
throw (
|
||||
"Invalid Netlify routes!\n" +
|
||||
invalidNetlifyRoutes
|
||||
.map((value) => JSON.stringify(value, null, 2))
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isServerSide(route) {
|
||||
return (
|
||||
route.kind === "prerender-with-fallback" || route.kind === "serverless"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isOnDemand
|
||||
* @param {string} htmlTemplate
|
||||
*/
|
||||
function rendererCode(isOnDemand, htmlTemplate) {
|
||||
return `const path = require("path");
|
||||
const busboy = require("busboy");
|
||||
const htmlTemplate = ${JSON.stringify(htmlTemplate)};
|
||||
|
||||
${
|
||||
isOnDemand
|
||||
? `const { builder } = require("@netlify/functions");
|
||||
|
||||
exports.handler = builder(render);`
|
||||
: `
|
||||
|
||||
exports.handler = render;`
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {import('aws-lambda').APIGatewayProxyEvent} event
|
||||
* @param {any} context
|
||||
*/
|
||||
async function render(event, context) {
|
||||
const requestTime = new Date();
|
||||
console.log(JSON.stringify(event));
|
||||
global.staticHttpCache = {};
|
||||
|
||||
const compiledElmPath = path.join(__dirname, "elm-pages-cli.js");
|
||||
const compiledPortsFile = path.join(__dirname, "port-data-source.js");
|
||||
const renderer = require("../../../../generator/src/render");
|
||||
const preRenderHtml = require("../../../../generator/src/pre-render-html");
|
||||
try {
|
||||
const basePath = "/";
|
||||
const mode = "build";
|
||||
const addWatcher = () => {};
|
||||
|
||||
const renderResult = await renderer(
|
||||
compiledPortsFile,
|
||||
basePath,
|
||||
require(compiledElmPath),
|
||||
mode,
|
||||
event.path,
|
||||
await reqToJson(event, requestTime),
|
||||
addWatcher,
|
||||
false
|
||||
);
|
||||
console.log("@@@renderResult", JSON.stringify(renderResult, null, 2));
|
||||
|
||||
const statusCode = renderResult.is404 ? 404 : renderResult.statusCode;
|
||||
|
||||
if (renderResult.kind === "bytes") {
|
||||
return {
|
||||
body: Buffer.from(renderResult.contentDatPayload.buffer).toString("base64"),
|
||||
isBase64Encoded: true,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"x-powered-by": "elm-pages",
|
||||
...renderResult.headers,
|
||||
},
|
||||
statusCode,
|
||||
};
|
||||
} else if (renderResult.kind === "api-response") {
|
||||
const serverResponse = renderResult.body;
|
||||
return {
|
||||
body: serverResponse.body,
|
||||
multiValueHeaders: serverResponse.headers,
|
||||
statusCode: serverResponse.statusCode,
|
||||
isBase64Encoded: serverResponse.isBase64Encoded,
|
||||
};
|
||||
} else {
|
||||
console.log('@rendering', preRenderHtml.replaceTemplate(htmlTemplate, renderResult.htmlString))
|
||||
return {
|
||||
body: preRenderHtml.replaceTemplate(htmlTemplate, renderResult.htmlString),
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"x-powered-by": "elm-pages",
|
||||
...renderResult.headers,
|
||||
},
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
body: \`<body><h1>Error</h1><pre>\${error.toString()}</pre></body>\`,
|
||||
statusCode: 500,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"x-powered-by": "elm-pages",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('aws-lambda').APIGatewayProxyEvent} req
|
||||
* @param {Date} requestTime
|
||||
* @returns {Promise<{ method: string; hostname: string; query: Record<string, string | undefined>; headers: Record<string, string>; host: string; pathname: string; port: number | null; protocol: string; rawUrl: string; }>}
|
||||
*/
|
||||
function reqToJson(req, requestTime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (
|
||||
req.httpMethod && req.httpMethod.toUpperCase() === "POST" &&
|
||||
req.headers["content-type"] &&
|
||||
req.headers["content-type"].includes("multipart/form-data") &&
|
||||
req.body
|
||||
) {
|
||||
try {
|
||||
console.log('@@@1');
|
||||
const bb = busboy({
|
||||
headers: req.headers,
|
||||
});
|
||||
let fields = {};
|
||||
|
||||
bb.on("file", (fieldname, file, info) => {
|
||||
console.log('@@@2');
|
||||
const { filename, encoding, mimeType } = info;
|
||||
|
||||
file.on("data", (data) => {
|
||||
fields[fieldname] = {
|
||||
filename,
|
||||
mimeType,
|
||||
body: data.toString(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bb.on("field", (fieldName, value) => {
|
||||
console.log("@@@field", fieldName, value);
|
||||
fields[fieldName] = value;
|
||||
});
|
||||
|
||||
// TODO skip parsing JSON and form data body if busboy doesn't run
|
||||
bb.on("close", () => {
|
||||
console.log('@@@3');
|
||||
console.log("@@@close", fields);
|
||||
resolve(toJsonHelper(req, requestTime, fields));
|
||||
});
|
||||
console.log('@@@4');
|
||||
|
||||
if (req.isBase64Encoded) {
|
||||
bb.write(Buffer.from(req.body, 'base64').toString('utf8'));
|
||||
} else {
|
||||
bb.write(req.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('@@@5', error);
|
||||
resolve(toJsonHelper(req, requestTime, null));
|
||||
}
|
||||
} else {
|
||||
console.log('@@@6');
|
||||
resolve(toJsonHelper(req, requestTime, null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('aws-lambda').APIGatewayProxyEvent} req
|
||||
* @param {Date} requestTime
|
||||
* @returns {{method: string; rawUrl: string; body: string?; headers: Record<string, string>; requestTime: number; multiPartFormData: unknown }}
|
||||
*/
|
||||
function toJsonHelper(req, requestTime, multiPartFormData) {
|
||||
return {
|
||||
method: req.httpMethod,
|
||||
headers: req.headers,
|
||||
rawUrl: req.rawUrl,
|
||||
body: req.body,
|
||||
requestTime: Math.round(requestTime.getTime()),
|
||||
multiPartFormData: multiPartFormData,
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {fs.PathLike} dirpath
|
||||
*/
|
||||
function ensureDirSync(dirpath) {
|
||||
try {
|
||||
fs.mkdirSync(dirpath, { recursive: true });
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {{kind: 'dynamic'} | {kind: 'literal', value: string}} ApiSegment */
|
||||
|
||||
/**
|
||||
* @param {ApiSegment[]} pathPattern
|
||||
*/
|
||||
function apiPatternToRedirectPattern(pathPattern) {
|
||||
return (
|
||||
"/" +
|
||||
pathPattern
|
||||
.map((segment, index) => {
|
||||
switch (segment.kind) {
|
||||
case "literal": {
|
||||
return segment.value;
|
||||
}
|
||||
case "dynamic": {
|
||||
return `:dynamic${index}`;
|
||||
}
|
||||
default: {
|
||||
throw "Unhandled segment: " + JSON.stringify(segment);
|
||||
}
|
||||
}
|
||||
})
|
||||
.join("/")
|
||||
);
|
||||
}
|
236
examples/todos/app/Api.elm
Normal file
236
examples/todos/app/Api.elm
Normal file
@ -0,0 +1,236 @@
|
||||
module Api exposing (routes)
|
||||
|
||||
import ApiRoute exposing (ApiRoute)
|
||||
import DataSource exposing (DataSource)
|
||||
import DataSource.Http
|
||||
import Html exposing (Html)
|
||||
import Json.Decode
|
||||
import Json.Encode
|
||||
import MySession
|
||||
import Pages.Manifest as Manifest
|
||||
import Route exposing (Route)
|
||||
import Server.Request
|
||||
import Server.Response
|
||||
import Server.Session as Session
|
||||
import Site
|
||||
|
||||
|
||||
routes :
|
||||
DataSource (List Route)
|
||||
-> (Html Never -> String)
|
||||
-> List (ApiRoute.ApiRoute ApiRoute.Response)
|
||||
routes getStaticRoutes htmlToString =
|
||||
[ --nonHybridRoute
|
||||
--, noArgs
|
||||
redirectRoute
|
||||
|
||||
--, repoStars
|
||||
--, repoStars2
|
||||
, logout
|
||||
|
||||
--, greet
|
||||
, fileLength
|
||||
, DataSource.succeed manifest |> Manifest.generator Site.canonicalUrl
|
||||
]
|
||||
|
||||
|
||||
fileLength : ApiRoute ApiRoute.Response
|
||||
fileLength =
|
||||
ApiRoute.succeed
|
||||
(Server.Request.expectMultiPartFormPost
|
||||
(\{ field, optionalField, fileField } ->
|
||||
fileField "file"
|
||||
)
|
||||
|> Server.Request.map
|
||||
(\file ->
|
||||
Server.Response.json
|
||||
(Json.Encode.object
|
||||
[ ( "File name: ", Json.Encode.string file.name )
|
||||
, ( "Length", Json.Encode.int (String.length file.body) )
|
||||
, ( "mime-type", Json.Encode.string file.mimeType )
|
||||
, ( "First line"
|
||||
, Json.Encode.string
|
||||
(file.body
|
||||
|> String.split "\n"
|
||||
|> List.head
|
||||
|> Maybe.withDefault ""
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|> DataSource.succeed
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "file"
|
||||
|> ApiRoute.serverRender
|
||||
|
||||
|
||||
redirectRoute : ApiRoute ApiRoute.Response
|
||||
redirectRoute =
|
||||
ApiRoute.succeed
|
||||
(Server.Request.succeed
|
||||
(DataSource.succeed
|
||||
(Route.redirectTo Route.Index)
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "redirect"
|
||||
|> ApiRoute.serverRender
|
||||
|
||||
|
||||
noArgs : ApiRoute ApiRoute.Response
|
||||
noArgs =
|
||||
ApiRoute.succeed
|
||||
(Server.Request.succeed
|
||||
(DataSource.Http.get
|
||||
"https://api.github.com/repos/dillonkearns/elm-pages"
|
||||
(Json.Decode.field "stargazers_count" Json.Decode.int)
|
||||
|> DataSource.map
|
||||
(\stars ->
|
||||
Json.Encode.object
|
||||
[ ( "repo", Json.Encode.string "elm-pages" )
|
||||
, ( "stars", Json.Encode.int stars )
|
||||
]
|
||||
|> Server.Response.json
|
||||
)
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "stars"
|
||||
|> ApiRoute.serverRender
|
||||
|
||||
|
||||
nonHybridRoute =
|
||||
ApiRoute.succeed
|
||||
(\repoName ->
|
||||
DataSource.Http.get
|
||||
("https://api.github.com/repos/dillonkearns/" ++ repoName)
|
||||
(Json.Decode.field "stargazers_count" Json.Decode.int)
|
||||
|> DataSource.map
|
||||
(\stars ->
|
||||
Json.Encode.object
|
||||
[ ( "repo", Json.Encode.string repoName )
|
||||
, ( "stars", Json.Encode.int stars )
|
||||
]
|
||||
|> Json.Encode.encode 2
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "repo"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.capture
|
||||
|> ApiRoute.preRender
|
||||
(\route ->
|
||||
DataSource.succeed
|
||||
[ route "elm-graphql"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
logout : ApiRoute ApiRoute.Response
|
||||
logout =
|
||||
ApiRoute.succeed
|
||||
(MySession.withSession
|
||||
(Server.Request.succeed ())
|
||||
(\() sessionResult ->
|
||||
DataSource.succeed
|
||||
( Session.empty
|
||||
, Route.redirectTo Route.Login
|
||||
)
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "logout"
|
||||
|> ApiRoute.serverRender
|
||||
|
||||
|
||||
repoStars : ApiRoute ApiRoute.Response
|
||||
repoStars =
|
||||
ApiRoute.succeed
|
||||
(\repoName ->
|
||||
Server.Request.succeed
|
||||
(DataSource.Http.get
|
||||
("https://api.github.com/repos/dillonkearns/" ++ repoName)
|
||||
(Json.Decode.field "stargazers_count" Json.Decode.int)
|
||||
|> DataSource.map
|
||||
(\stars ->
|
||||
Json.Encode.object
|
||||
[ ( "repo", Json.Encode.string repoName )
|
||||
, ( "stars", Json.Encode.int stars )
|
||||
]
|
||||
|> Server.Response.json
|
||||
)
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "repo"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.capture
|
||||
--|> ApiRoute.literal ".json"
|
||||
|> ApiRoute.serverRender
|
||||
|
||||
|
||||
repoStars2 : ApiRoute ApiRoute.Response
|
||||
repoStars2 =
|
||||
ApiRoute.succeed
|
||||
(\repoName ->
|
||||
DataSource.Http.get
|
||||
("https://api.github.com/repos/dillonkearns/" ++ repoName)
|
||||
(Json.Decode.field "stargazers_count" Json.Decode.int)
|
||||
|> DataSource.map
|
||||
(\stars ->
|
||||
Json.Encode.object
|
||||
[ ( "repo", Json.Encode.string repoName )
|
||||
, ( "stars", Json.Encode.int stars )
|
||||
]
|
||||
|> Server.Response.json
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "api2"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.literal "repo"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.capture
|
||||
|> ApiRoute.preRenderWithFallback
|
||||
(\route ->
|
||||
DataSource.succeed
|
||||
[ route "elm-graphql"
|
||||
, route "elm-pages"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
route1 =
|
||||
ApiRoute.succeed
|
||||
(\repoName ->
|
||||
DataSource.Http.get
|
||||
("https://api.github.com/repos/dillonkearns/" ++ repoName)
|
||||
(Json.Decode.field "stargazers_count" Json.Decode.int)
|
||||
|> DataSource.map
|
||||
(\stars ->
|
||||
Json.Encode.object
|
||||
[ ( "repo", Json.Encode.string repoName )
|
||||
, ( "stars", Json.Encode.int stars )
|
||||
]
|
||||
|> Json.Encode.encode 2
|
||||
)
|
||||
)
|
||||
|> ApiRoute.literal "repo"
|
||||
|> ApiRoute.slash
|
||||
|> ApiRoute.capture
|
||||
|> ApiRoute.literal ".json"
|
||||
|
||||
|
||||
manifest : Manifest.Config
|
||||
manifest =
|
||||
Manifest.init
|
||||
{ name = "Site Name"
|
||||
, description = "Description"
|
||||
, startUrl = Route.Index |> Route.toPath
|
||||
, icons = []
|
||||
}
|
136
examples/todos/app/Effect.elm
Normal file
136
examples/todos/app/Effect.elm
Normal file
@ -0,0 +1,136 @@
|
||||
module Effect exposing (Effect(..), batch, fromCmd, map, none, perform)
|
||||
|
||||
import Browser.Navigation
|
||||
import Bytes exposing (Bytes)
|
||||
import Bytes.Decode
|
||||
import FormDecoder
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Pages.Fetcher
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type Effect msg
|
||||
= None
|
||||
| Cmd (Cmd msg)
|
||||
| Batch (List (Effect msg))
|
||||
| GetStargazers (Result Http.Error Int -> msg)
|
||||
| SetField { formId : String, name : String, value : String }
|
||||
| FetchRouteData
|
||||
{ data : Maybe FormDecoder.FormData
|
||||
, toMsg : Result Http.Error Url -> msg
|
||||
}
|
||||
| Submit
|
||||
{ values : FormDecoder.FormData
|
||||
, toMsg : Result Http.Error Url -> msg
|
||||
}
|
||||
| SubmitFetcher (Pages.Fetcher.Fetcher msg)
|
||||
|
||||
|
||||
type alias RequestInfo =
|
||||
{ contentType : String
|
||||
, body : String
|
||||
}
|
||||
|
||||
|
||||
none : Effect msg
|
||||
none =
|
||||
None
|
||||
|
||||
|
||||
batch : List (Effect msg) -> Effect msg
|
||||
batch =
|
||||
Batch
|
||||
|
||||
|
||||
fromCmd : Cmd msg -> Effect msg
|
||||
fromCmd =
|
||||
Cmd
|
||||
|
||||
|
||||
map : (a -> b) -> Effect a -> Effect b
|
||||
map fn effect =
|
||||
case effect of
|
||||
None ->
|
||||
None
|
||||
|
||||
Cmd cmd ->
|
||||
Cmd (Cmd.map fn cmd)
|
||||
|
||||
Batch list ->
|
||||
Batch (List.map (map fn) list)
|
||||
|
||||
GetStargazers toMsg ->
|
||||
GetStargazers (toMsg >> fn)
|
||||
|
||||
FetchRouteData fetchInfo ->
|
||||
FetchRouteData
|
||||
{ data = fetchInfo.data
|
||||
, toMsg = fetchInfo.toMsg >> fn
|
||||
}
|
||||
|
||||
Submit fetchInfo ->
|
||||
Submit
|
||||
{ values = fetchInfo.values
|
||||
, toMsg = fetchInfo.toMsg >> fn
|
||||
}
|
||||
|
||||
SetField info ->
|
||||
SetField info
|
||||
|
||||
SubmitFetcher fetcher ->
|
||||
fetcher
|
||||
|> Pages.Fetcher.map fn
|
||||
|> SubmitFetcher
|
||||
|
||||
|
||||
perform :
|
||||
{ fetchRouteData :
|
||||
{ data : Maybe FormDecoder.FormData
|
||||
, toMsg : Result Http.Error Url -> pageMsg
|
||||
}
|
||||
-> Cmd msg
|
||||
, submit :
|
||||
{ values : FormDecoder.FormData
|
||||
, toMsg : Result Http.Error Url -> pageMsg
|
||||
}
|
||||
-> Cmd msg
|
||||
, runFetcher :
|
||||
Pages.Fetcher.Fetcher pageMsg
|
||||
-> Cmd msg
|
||||
, fromPageMsg : pageMsg -> msg
|
||||
, key : Browser.Navigation.Key
|
||||
, setField : { formId : String, name : String, value : String } -> Cmd msg
|
||||
}
|
||||
-> Effect pageMsg
|
||||
-> Cmd msg
|
||||
perform ({ fromPageMsg, key } as helpers) effect =
|
||||
case effect of
|
||||
None ->
|
||||
Cmd.none
|
||||
|
||||
Cmd cmd ->
|
||||
Cmd.map fromPageMsg cmd
|
||||
|
||||
SetField info ->
|
||||
helpers.setField info
|
||||
|
||||
Batch list ->
|
||||
Cmd.batch (List.map (perform helpers) list)
|
||||
|
||||
GetStargazers toMsg ->
|
||||
Http.get
|
||||
{ url =
|
||||
"https://api.github.com/repos/dillonkearns/elm-pages"
|
||||
, expect = Http.expectJson (toMsg >> fromPageMsg) (Decode.field "stargazers_count" Decode.int)
|
||||
}
|
||||
|
||||
FetchRouteData fetchInfo ->
|
||||
helpers.fetchRouteData
|
||||
fetchInfo
|
||||
|
||||
Submit record ->
|
||||
helpers.submit record
|
||||
|
||||
SubmitFetcher record ->
|
||||
helpers.runFetcher record
|
78
examples/todos/app/ErrorPage.elm
Normal file
78
examples/todos/app/ErrorPage.elm
Normal file
@ -0,0 +1,78 @@
|
||||
module ErrorPage exposing (ErrorPage(..), Model, Msg, head, init, internalError, notFound, statusCode, update, view)
|
||||
|
||||
import Effect exposing (Effect)
|
||||
import Head
|
||||
import Html exposing (Html)
|
||||
import Html.Events exposing (onClick)
|
||||
import Route
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type Msg
|
||||
= Increment
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ count : Int
|
||||
}
|
||||
|
||||
|
||||
init : ErrorPage -> ( Model, Effect Msg )
|
||||
init errorPage =
|
||||
( { count = 0 }
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
|
||||
update : ErrorPage -> Msg -> Model -> ( Model, Effect Msg )
|
||||
update errorPage msg model =
|
||||
case msg of
|
||||
Increment ->
|
||||
( { model | count = model.count + 1 }, Effect.none )
|
||||
|
||||
|
||||
head : ErrorPage -> List Head.Tag
|
||||
head errorPage =
|
||||
[]
|
||||
|
||||
|
||||
type ErrorPage
|
||||
= NotFound
|
||||
| InternalError String
|
||||
|
||||
|
||||
notFound : ErrorPage
|
||||
notFound =
|
||||
NotFound
|
||||
|
||||
|
||||
internalError : String -> ErrorPage
|
||||
internalError =
|
||||
InternalError
|
||||
|
||||
|
||||
view : ErrorPage -> Model -> View Msg
|
||||
view error model =
|
||||
case error of
|
||||
_ ->
|
||||
{ body =
|
||||
[ Html.div []
|
||||
[ Html.p []
|
||||
[ Html.text "Let's find you a nice refreshing smoothie. Check out "
|
||||
, Route.Index |> Route.link [] [ Html.text "our menu" ]
|
||||
]
|
||||
, Html.div [] []
|
||||
]
|
||||
]
|
||||
, title = "This is a NotFound Error"
|
||||
}
|
||||
|
||||
|
||||
statusCode : ErrorPage -> number
|
||||
statusCode error =
|
||||
case error of
|
||||
NotFound ->
|
||||
404
|
||||
|
||||
InternalError _ ->
|
||||
500
|
128
examples/todos/app/Route/Index.elm
Normal file
128
examples/todos/app/Route/Index.elm
Normal file
@ -0,0 +1,128 @@
|
||||
module Route.Index exposing (ActionData, Data, Model, Msg, route)
|
||||
|
||||
import Api.Scalar exposing (Uuid(..))
|
||||
import DataSource exposing (DataSource)
|
||||
import Effect exposing (Effect)
|
||||
import ErrorPage exposing (ErrorPage)
|
||||
import Form
|
||||
import Form.Validation as Validation
|
||||
import Head
|
||||
import Html exposing (Html)
|
||||
import Pages.Msg
|
||||
import Pages.PageUrl exposing (PageUrl)
|
||||
import Path exposing (Path)
|
||||
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
|
||||
import Seo.Common
|
||||
import Server.Request as Request
|
||||
import Server.Response as Response exposing (Response)
|
||||
import Shared
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{}
|
||||
|
||||
|
||||
type Msg
|
||||
= NoOp
|
||||
|
||||
|
||||
type alias RouteParams =
|
||||
{}
|
||||
|
||||
|
||||
type alias Data =
|
||||
{}
|
||||
|
||||
|
||||
type alias ActionData =
|
||||
{}
|
||||
|
||||
|
||||
route : StatefulRoute RouteParams Data ActionData Model Msg
|
||||
route =
|
||||
RouteBuilder.serverRender
|
||||
{ head = head
|
||||
, data = data
|
||||
, action = action
|
||||
}
|
||||
|> RouteBuilder.buildWithLocalState
|
||||
{ view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, init = init
|
||||
}
|
||||
|
||||
|
||||
init :
|
||||
Maybe PageUrl
|
||||
-> Shared.Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> ( Model, Effect Msg )
|
||||
init maybePageUrl sharedModel static =
|
||||
( {}, Effect.none )
|
||||
|
||||
|
||||
update :
|
||||
PageUrl
|
||||
-> Shared.Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> Msg
|
||||
-> Model
|
||||
-> ( Model, Effect Msg )
|
||||
update pageUrl sharedModel static msg model =
|
||||
case msg of
|
||||
NoOp ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
subscriptions : Maybe PageUrl -> RouteParams -> Path -> Shared.Model -> Model -> Sub Msg
|
||||
subscriptions maybePageUrl routeParams path sharedModel model =
|
||||
Sub.none
|
||||
|
||||
|
||||
head :
|
||||
StaticPayload Data ActionData RouteParams
|
||||
-> List Head.Tag
|
||||
head static =
|
||||
Seo.Common.tags
|
||||
|
||||
|
||||
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
|
||||
data routeParams =
|
||||
Request.succeed (DataSource.succeed (Response.render {}))
|
||||
|
||||
|
||||
type Action
|
||||
= Signout
|
||||
| SetQuantity Uuid Int
|
||||
|
||||
|
||||
signoutForm : Form.HtmlForm String Action input Msg
|
||||
signoutForm =
|
||||
Form.init
|
||||
{ combine = Validation.succeed Signout
|
||||
, view =
|
||||
\formState ->
|
||||
[ Html.button [] [ Html.text "Sign out" ]
|
||||
]
|
||||
}
|
||||
|> Form.hiddenKind ( "kind", "signout" ) "Expected signout"
|
||||
|
||||
|
||||
action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage))
|
||||
action routeParams =
|
||||
Request.skip ""
|
||||
|
||||
|
||||
view :
|
||||
Maybe PageUrl
|
||||
-> Shared.Model
|
||||
-> Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> View (Pages.Msg.Msg Msg)
|
||||
view maybeUrl sharedModel model app =
|
||||
{ title = "Ctrl-R Smoothies"
|
||||
, body =
|
||||
[]
|
||||
}
|
271
examples/todos/app/Route/Login.elm
Normal file
271
examples/todos/app/Route/Login.elm
Normal file
@ -0,0 +1,271 @@
|
||||
module Route.Login exposing (ActionData, Data, Model, Msg, route)
|
||||
|
||||
import Api.Scalar exposing (Uuid(..))
|
||||
import Data.User
|
||||
import DataSource exposing (DataSource)
|
||||
import DataSource.Port
|
||||
import Dict exposing (Dict)
|
||||
import ErrorPage exposing (ErrorPage)
|
||||
import Form
|
||||
import Form.Field as Field
|
||||
import Form.FieldView
|
||||
import Form.Validation as Validation exposing (Combined, Field)
|
||||
import Head
|
||||
import Head.Seo as Seo
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Json.Decode
|
||||
import Json.Encode
|
||||
import MySession
|
||||
import Pages.Msg
|
||||
import Pages.PageUrl exposing (PageUrl)
|
||||
import Pages.Url
|
||||
import Request.Hasura
|
||||
import Route
|
||||
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
|
||||
import Server.Request as Request
|
||||
import Server.Response exposing (Response)
|
||||
import Server.Session as Session
|
||||
import Shared
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{}
|
||||
|
||||
|
||||
type alias Msg =
|
||||
()
|
||||
|
||||
|
||||
type alias RouteParams =
|
||||
{}
|
||||
|
||||
|
||||
route : StatelessRoute RouteParams Data ActionData
|
||||
route =
|
||||
RouteBuilder.serverRender
|
||||
{ head = head
|
||||
, data = data
|
||||
, action = action
|
||||
}
|
||||
|> RouteBuilder.buildNoState { view = view }
|
||||
|
||||
|
||||
type alias Login =
|
||||
{ username : String
|
||||
, password : String
|
||||
}
|
||||
|
||||
|
||||
form : Form.DoneForm String (DataSource (Combined String String)) data (List (Html (Pages.Msg.Msg Msg)))
|
||||
form =
|
||||
Form.init
|
||||
(\username password ->
|
||||
{ combine =
|
||||
Validation.succeed
|
||||
(\u p ->
|
||||
attemptLogIn u p
|
||||
|> DataSource.map
|
||||
(\maybeUserId ->
|
||||
case maybeUserId of
|
||||
Just (Uuid userId) ->
|
||||
Validation.succeed userId
|
||||
|
||||
Nothing ->
|
||||
Validation.fail "Username and password do not match" Validation.global
|
||||
)
|
||||
)
|
||||
|> Validation.andMap username
|
||||
|> Validation.andMap password
|
||||
, view =
|
||||
\info ->
|
||||
[ username |> fieldView info "Username"
|
||||
, password |> fieldView info "Password"
|
||||
, globalErrors info
|
||||
, Html.button []
|
||||
[ if info.isTransitioning then
|
||||
Html.text "Logging in..."
|
||||
|
||||
else
|
||||
Html.text "Login"
|
||||
]
|
||||
]
|
||||
}
|
||||
)
|
||||
|> Form.field "username" (Field.text |> Field.email |> Field.required "Required")
|
||||
|> Form.field "password" (Field.text |> Field.password |> Field.required "Required")
|
||||
|
||||
|
||||
attemptLogIn : String -> String -> DataSource (Maybe Uuid)
|
||||
attemptLogIn username password =
|
||||
--DataSource.Port.get "hashPassword"
|
||||
-- (Json.Encode.string password)
|
||||
-- Json.Decode.string
|
||||
-- |> DataSource.andThen
|
||||
-- (\hashed ->
|
||||
-- { username = username
|
||||
-- , expectedPasswordHash = hashed
|
||||
-- }
|
||||
-- |> Data.User.login
|
||||
-- |> Request.Hasura.dataSource
|
||||
-- )
|
||||
DataSource.fail ""
|
||||
|
||||
|
||||
fieldView :
|
||||
Form.Context String data
|
||||
-> String
|
||||
-> Field String parsed Form.FieldView.Input
|
||||
-> Html msg
|
||||
fieldView formState label field =
|
||||
Html.div []
|
||||
[ Html.label []
|
||||
[ Html.text (label ++ " ")
|
||||
, field |> Form.FieldView.input []
|
||||
]
|
||||
, errorsForField formState field
|
||||
]
|
||||
|
||||
|
||||
errorsForField : Form.Context String data -> Field String parsed kind -> Html msg
|
||||
errorsForField formState field =
|
||||
(if True || formState.submitAttempted then
|
||||
formState.errors
|
||||
|> Form.errorsForField field
|
||||
|> List.map (\error -> Html.li [] [ Html.text error ])
|
||||
|
||||
else
|
||||
[]
|
||||
)
|
||||
|> Html.ul [ Attr.style "color" "red" ]
|
||||
|
||||
|
||||
globalErrors : Form.Context String data -> Html msg
|
||||
globalErrors formState =
|
||||
formState.errors
|
||||
|> Form.errorsForField Validation.global
|
||||
|> List.map (\error -> Html.li [] [ Html.text error ])
|
||||
|> Html.ul [ Attr.style "color" "red" ]
|
||||
|
||||
|
||||
type alias Request =
|
||||
{ cookies : Dict String String
|
||||
, maybeFormData : Maybe (Dict String ( String, List String ))
|
||||
}
|
||||
|
||||
|
||||
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
|
||||
data routeParams =
|
||||
MySession.withSession
|
||||
(Request.succeed ())
|
||||
(\() session ->
|
||||
case session of
|
||||
Ok (Just okSession) ->
|
||||
( okSession
|
||||
, okSession
|
||||
|> Session.get "userId"
|
||||
|> Data
|
||||
|> Server.Response.render
|
||||
)
|
||||
|> DataSource.succeed
|
||||
|
||||
_ ->
|
||||
( Session.empty
|
||||
, { username = Nothing }
|
||||
|> Server.Response.render
|
||||
)
|
||||
|> DataSource.succeed
|
||||
)
|
||||
|
||||
|
||||
action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage))
|
||||
action routeParams =
|
||||
MySession.withSession
|
||||
(Request.formDataWithServerValidation [ form ])
|
||||
(\usernameDs session ->
|
||||
usernameDs
|
||||
|> DataSource.andThen
|
||||
(\usernameResult ->
|
||||
case usernameResult of
|
||||
Err error ->
|
||||
( session
|
||||
|> Result.withDefault Nothing
|
||||
|> Maybe.withDefault Session.empty
|
||||
, error |> render
|
||||
)
|
||||
|> DataSource.succeed
|
||||
|
||||
Ok ( _, userId ) ->
|
||||
( session
|
||||
|> Result.withDefault Nothing
|
||||
|> Maybe.withDefault Session.empty
|
||||
|> Session.insert "userId" userId
|
||||
, Route.redirectTo Route.Index
|
||||
)
|
||||
|> DataSource.succeed
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
render :
|
||||
Form.Response error
|
||||
-> Response { fields : List ( String, String ), errors : Dict String (List error) } a
|
||||
render (Form.Response response) =
|
||||
Server.Response.render response
|
||||
|
||||
|
||||
head :
|
||||
StaticPayload Data ActionData RouteParams
|
||||
-> List Head.Tag
|
||||
head static =
|
||||
Seo.summary
|
||||
{ canonicalUrlOverride = Nothing
|
||||
, siteName = "elm-pages"
|
||||
, image =
|
||||
{ url = Pages.Url.external "TODO"
|
||||
, alt = "elm-pages logo"
|
||||
, dimensions = Nothing
|
||||
, mimeType = Nothing
|
||||
}
|
||||
, description = "TODO"
|
||||
, locale = Nothing
|
||||
, title = "TODO title" -- metadata.title -- TODO
|
||||
}
|
||||
|> Seo.website
|
||||
|
||||
|
||||
type alias Data =
|
||||
{ username : Maybe String
|
||||
}
|
||||
|
||||
|
||||
type alias ActionData =
|
||||
{ fields : List ( String, String )
|
||||
, errors : Dict String (List String)
|
||||
}
|
||||
|
||||
|
||||
view :
|
||||
Maybe PageUrl
|
||||
-> Shared.Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> View (Pages.Msg.Msg Msg)
|
||||
view maybeUrl sharedModel app =
|
||||
{ title = "Login"
|
||||
, body =
|
||||
[ Html.p []
|
||||
[ Html.text
|
||||
(case app.data.username of
|
||||
Just username ->
|
||||
"Hello! You are already logged in."
|
||||
|
||||
Nothing ->
|
||||
"You aren't logged in yet."
|
||||
)
|
||||
]
|
||||
, form
|
||||
|> Form.toDynamicTransition "login"
|
||||
|> Form.renderHtml [] app.action app ()
|
||||
]
|
||||
}
|
227
examples/todos/app/Route/Signup.elm
Normal file
227
examples/todos/app/Route/Signup.elm
Normal file
@ -0,0 +1,227 @@
|
||||
module Route.Signup exposing (ActionData, Data, Model, Msg, route)
|
||||
|
||||
import DataSource exposing (DataSource)
|
||||
import Dict
|
||||
import Effect exposing (Effect)
|
||||
import ErrorPage exposing (ErrorPage)
|
||||
import Head
|
||||
import Head.Seo as Seo
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Http
|
||||
import MySession
|
||||
import Pages.Msg
|
||||
import Pages.PageUrl exposing (PageUrl)
|
||||
import Pages.Url
|
||||
import Path exposing (Path)
|
||||
import Route
|
||||
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
|
||||
import Server.Request as Request
|
||||
import Server.Response as Response exposing (Response)
|
||||
import Server.Session as Session exposing (Session)
|
||||
import Shared
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{}
|
||||
|
||||
|
||||
type Msg
|
||||
= NoOp
|
||||
| GotResponse (Result Http.Error ActionData)
|
||||
|
||||
|
||||
type alias RouteParams =
|
||||
{}
|
||||
|
||||
|
||||
route : StatefulRoute RouteParams Data ActionData Model Msg
|
||||
route =
|
||||
RouteBuilder.serverRender
|
||||
{ head = head
|
||||
, data = data
|
||||
, action = action
|
||||
}
|
||||
|> RouteBuilder.buildWithLocalState
|
||||
{ view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, init = init
|
||||
}
|
||||
|
||||
|
||||
action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage))
|
||||
action _ =
|
||||
MySession.withSession
|
||||
(Request.skip "TODO")
|
||||
--(Request.expectFormPost
|
||||
-- (\{ field } ->
|
||||
-- Request.map2 Tuple.pair
|
||||
-- (field "first")
|
||||
-- (field "email")
|
||||
-- )
|
||||
--)
|
||||
(\( first, email ) maybeSession ->
|
||||
let
|
||||
session : Session
|
||||
session =
|
||||
maybeSession |> Result.toMaybe |> Maybe.andThen identity |> Maybe.withDefault Session.empty
|
||||
in
|
||||
validate session
|
||||
{ email = email
|
||||
, first = first
|
||||
}
|
||||
|> DataSource.succeed
|
||||
)
|
||||
|
||||
|
||||
validate : Session -> { first : String, email : String } -> ( Session, Response ActionData ErrorPage )
|
||||
validate session { first, email } =
|
||||
if first /= "" && email /= "" then
|
||||
( session
|
||||
|> Session.withFlash "message" ("Success! You're all signed up " ++ first)
|
||||
, Route.redirectTo Route.Signup
|
||||
)
|
||||
|
||||
else
|
||||
( session
|
||||
, ValidationErrors
|
||||
{ errors = [ "Cannot be blank?" ]
|
||||
, fields =
|
||||
[ ( "first", first )
|
||||
, ( "email", email )
|
||||
]
|
||||
}
|
||||
|> Response.render
|
||||
)
|
||||
|
||||
|
||||
init :
|
||||
Maybe PageUrl
|
||||
-> Shared.Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> ( Model, Effect Msg )
|
||||
init maybePageUrl sharedModel static =
|
||||
( {}
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
|
||||
update :
|
||||
PageUrl
|
||||
-> Shared.Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> Msg
|
||||
-> Model
|
||||
-> ( Model, Effect Msg )
|
||||
update pageUrl sharedModel static msg model =
|
||||
case msg of
|
||||
NoOp ->
|
||||
( model, Effect.none )
|
||||
|
||||
GotResponse result ->
|
||||
let
|
||||
_ =
|
||||
Debug.log "GotResponse" result
|
||||
in
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
subscriptions : Maybe PageUrl -> RouteParams -> Path -> Shared.Model -> Model -> Sub Msg
|
||||
subscriptions maybePageUrl routeParams path sharedModel model =
|
||||
Sub.none
|
||||
|
||||
|
||||
type alias Data =
|
||||
{ flashMessage : Maybe (Result String String)
|
||||
}
|
||||
|
||||
|
||||
type ActionData
|
||||
= Success { email : String, first : String }
|
||||
| ValidationErrors
|
||||
{ errors : List String
|
||||
, fields : List ( String, String )
|
||||
}
|
||||
|
||||
|
||||
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
|
||||
data routeParams =
|
||||
MySession.withSession
|
||||
(Request.succeed ())
|
||||
(\() sessionResult ->
|
||||
let
|
||||
session : Session
|
||||
session =
|
||||
sessionResult |> Result.toMaybe |> Maybe.andThen identity |> Maybe.withDefault Session.empty
|
||||
|
||||
flashMessage : Maybe String
|
||||
flashMessage =
|
||||
session |> Session.get "message"
|
||||
in
|
||||
( Session.empty
|
||||
, Response.render
|
||||
{ flashMessage = flashMessage |> Maybe.map Ok }
|
||||
)
|
||||
|> DataSource.succeed
|
||||
)
|
||||
|
||||
|
||||
head :
|
||||
StaticPayload Data ActionData RouteParams
|
||||
-> List Head.Tag
|
||||
head static =
|
||||
[]
|
||||
|
||||
|
||||
view :
|
||||
Maybe PageUrl
|
||||
-> Shared.Model
|
||||
-> Model
|
||||
-> StaticPayload Data ActionData RouteParams
|
||||
-> View (Pages.Msg.Msg Msg)
|
||||
view maybeUrl sharedModel model static =
|
||||
{ title = "Signup"
|
||||
, body =
|
||||
[ Html.p []
|
||||
[ case static.action of
|
||||
Just (Success { email, first }) ->
|
||||
Html.text <| "Hello " ++ first ++ "!"
|
||||
|
||||
Just (ValidationErrors { errors }) ->
|
||||
errors
|
||||
|> List.map (\error -> Html.li [] [ Html.text error ])
|
||||
|> Html.ul []
|
||||
|
||||
_ ->
|
||||
Html.text ""
|
||||
]
|
||||
, flashView static.data.flashMessage
|
||||
, Html.form
|
||||
[ Attr.method "POST"
|
||||
]
|
||||
[ Html.label [] [ Html.text "First", Html.input [ Attr.name "first" ] [] ]
|
||||
, Html.label [] [ Html.text "Email", Html.input [ Attr.name "email" ] [] ]
|
||||
, Html.input [ Attr.type_ "submit", Attr.value "Signup" ] []
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
flashView : Maybe (Result String String) -> Html msg
|
||||
flashView message =
|
||||
Html.p
|
||||
[ Attr.style "background-color" "rgb(163 251 163)"
|
||||
]
|
||||
[ Html.text <|
|
||||
case message of
|
||||
Nothing ->
|
||||
""
|
||||
|
||||
Just (Ok okMessage) ->
|
||||
okMessage
|
||||
|
||||
Just (Err error) ->
|
||||
"Something went wrong: " ++ error
|
||||
]
|
119
examples/todos/app/Shared.elm
Normal file
119
examples/todos/app/Shared.elm
Normal file
@ -0,0 +1,119 @@
|
||||
module Shared exposing (Data, Model, Msg(..), SharedMsg(..), template)
|
||||
|
||||
import DataSource
|
||||
import Effect exposing (Effect)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as Attr
|
||||
import Pages.Flags
|
||||
import Pages.PageUrl exposing (PageUrl)
|
||||
import Path exposing (Path)
|
||||
import Route exposing (Route)
|
||||
import SharedTemplate exposing (SharedTemplate)
|
||||
import View exposing (View)
|
||||
|
||||
|
||||
template : SharedTemplate Msg Model Data msg
|
||||
template =
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, data = data
|
||||
, subscriptions = subscriptions
|
||||
, onPageChange = Just OnPageChange
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= OnPageChange
|
||||
{ path : Path
|
||||
, query : Maybe String
|
||||
, fragment : Maybe String
|
||||
}
|
||||
| SharedMsg SharedMsg
|
||||
|
||||
|
||||
type alias Data =
|
||||
()
|
||||
|
||||
|
||||
type SharedMsg
|
||||
= NoOp
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ showMobileMenu : Bool
|
||||
}
|
||||
|
||||
|
||||
init :
|
||||
Pages.Flags.Flags
|
||||
->
|
||||
Maybe
|
||||
{ path :
|
||||
{ path : Path
|
||||
, query : Maybe String
|
||||
, fragment : Maybe String
|
||||
}
|
||||
, metadata : route
|
||||
, pageUrl : Maybe PageUrl
|
||||
}
|
||||
-> ( Model, Effect Msg )
|
||||
init flags maybePagePath =
|
||||
( { showMobileMenu = False }
|
||||
, Effect.none
|
||||
)
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Effect Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
OnPageChange _ ->
|
||||
( { model | showMobileMenu = False }, Effect.none )
|
||||
|
||||
SharedMsg globalMsg ->
|
||||
( model, Effect.none )
|
||||
|
||||
|
||||
subscriptions : Path -> Model -> Sub Msg
|
||||
subscriptions _ _ =
|
||||
Sub.none
|
||||
|
||||
|
||||
data : DataSource.DataSource Data
|
||||
data =
|
||||
DataSource.succeed ()
|
||||
|
||||
|
||||
view :
|
||||
Data
|
||||
->
|
||||
{ path : Path
|
||||
, route : Maybe Route
|
||||
}
|
||||
-> Model
|
||||
-> (Msg -> msg)
|
||||
-> View msg
|
||||
-> { body : Html msg, title : String }
|
||||
view sharedData page model toMsg pageView =
|
||||
{ body =
|
||||
Html.div
|
||||
[]
|
||||
[ Html.nav
|
||||
[ Attr.style "display" "flex"
|
||||
, Attr.style "justify-content" "space-evenly"
|
||||
]
|
||||
[ Html.span [ Attr.class "icon" ]
|
||||
[ Html.text " "
|
||||
, Html.kbd [] [ Html.text "Ctrl" ]
|
||||
, Html.text "+"
|
||||
, Html.kbd [] [ Html.text "R" ]
|
||||
, Html.text " Smoothies"
|
||||
]
|
||||
]
|
||||
, Html.div
|
||||
[ Attr.style "padding" "40px"
|
||||
]
|
||||
pageView.body
|
||||
]
|
||||
, title = pageView.title
|
||||
}
|
46
examples/todos/app/Site.elm
Normal file
46
examples/todos/app/Site.elm
Normal file
@ -0,0 +1,46 @@
|
||||
module Site exposing (canonicalUrl, config)
|
||||
|
||||
import DataSource exposing (DataSource)
|
||||
import Head
|
||||
import Route exposing (Route)
|
||||
import SiteConfig exposing (SiteConfig)
|
||||
import Sitemap
|
||||
|
||||
|
||||
type alias Data =
|
||||
()
|
||||
|
||||
|
||||
config : SiteConfig
|
||||
config =
|
||||
{ canonicalUrl = canonicalUrl
|
||||
, head = head
|
||||
}
|
||||
|
||||
|
||||
canonicalUrl : String
|
||||
canonicalUrl =
|
||||
"https://elm-pages.com"
|
||||
|
||||
|
||||
head : DataSource (List Head.Tag)
|
||||
head =
|
||||
[ Head.sitemapLink "/sitemap.xml"
|
||||
]
|
||||
|> DataSource.succeed
|
||||
|
||||
|
||||
siteMap :
|
||||
List (Maybe Route)
|
||||
-> { path : List String, content : String }
|
||||
siteMap allRoutes =
|
||||
allRoutes
|
||||
|> List.filterMap identity
|
||||
|> List.map
|
||||
(\route ->
|
||||
{ path = Route.routeToPath route |> String.join "/"
|
||||
, lastMod = Nothing
|
||||
}
|
||||
)
|
||||
|> Sitemap.build { siteUrl = "https://elm-pages.com" }
|
||||
|> (\sitemapXmlString -> { path = [ "sitemap.xml" ], content = sitemapXmlString })
|
23
examples/todos/app/View.elm
Normal file
23
examples/todos/app/View.elm
Normal file
@ -0,0 +1,23 @@
|
||||
module View exposing (View, map, placeholder)
|
||||
|
||||
import Html exposing (Html)
|
||||
|
||||
|
||||
type alias View msg =
|
||||
{ title : String
|
||||
, body : List (Html msg)
|
||||
}
|
||||
|
||||
|
||||
map : (msg1 -> msg2) -> View msg1 -> View msg2
|
||||
map fn doc =
|
||||
{ title = doc.title
|
||||
, body = List.map (Html.map fn) doc.body
|
||||
}
|
||||
|
||||
|
||||
placeholder : String -> View msg
|
||||
placeholder moduleName =
|
||||
{ title = "Placeholder - " ++ moduleName
|
||||
, body = [ Html.text moduleName ]
|
||||
}
|
8
examples/todos/elm-application.json
Normal file
8
examples/todos/elm-application.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "dmy/elm-doc-preview",
|
||||
"summary": "Offline documentation previewer",
|
||||
"version": "5.0.0",
|
||||
"exposed-modules": [
|
||||
"Page"
|
||||
]
|
||||
}
|
8
examples/todos/elm-pages.config.mjs
Normal file
8
examples/todos/elm-pages.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import adapter from "./adapter.mjs";
|
||||
|
||||
export default {
|
||||
vite: defineConfig({}),
|
||||
adapter,
|
||||
};
|
8
examples/todos/elm-tooling.json
Normal file
8
examples/todos/elm-tooling.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"tools": {
|
||||
"elm": "0.19.1",
|
||||
"elm-format": "0.8.4",
|
||||
"elm-json": "0.2.10",
|
||||
"elm-test-rs": "1.0.0"
|
||||
}
|
||||
}
|
68
examples/todos/elm.json
Normal file
68
examples/todos/elm.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src",
|
||||
"app",
|
||||
"../../src",
|
||||
".elm-pages",
|
||||
"../../plugins",
|
||||
"gen"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"MartinSStewart/elm-nonempty-string": "2.0.0",
|
||||
"MartinSStewart/elm-serialize": "1.2.5",
|
||||
"avh4/elm-color": "1.0.0",
|
||||
"danfishgold/base64-bytes": "1.1.0",
|
||||
"danyx23/elm-mimetype": "4.0.1",
|
||||
"dillonkearns/elm-bcp47-language-tag": "1.0.1",
|
||||
"dillonkearns/elm-graphql": "5.0.9",
|
||||
"dillonkearns/elm-markdown": "6.0.1",
|
||||
"dillonkearns/elm-sitemap": "1.0.1",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.3",
|
||||
"elm-community/dict-extra": "2.4.0",
|
||||
"elm-community/list-extra": "8.3.0",
|
||||
"jluckyiv/elm-utc-date-strings": "1.0.0",
|
||||
"justinmimbs/date": "4.0.0",
|
||||
"lamdera/codecs": "1.0.0",
|
||||
"lamdera/core": "1.0.0",
|
||||
"miniBill/elm-codec": "1.2.0",
|
||||
"noahzgordon/elm-color-extra": "1.0.2",
|
||||
"pablohirafuji/elm-syntax-highlight": "3.4.0",
|
||||
"robinheghan/fnv1a": "1.0.0",
|
||||
"robinheghan/murmur3": "1.0.0",
|
||||
"rtfeldman/elm-css": "16.1.1",
|
||||
"tripokey/elm-fuzzy": "5.2.1",
|
||||
"turboMaCk/non-empty-list-alias": "1.2.0",
|
||||
"vito/elm-ansi": "10.0.1",
|
||||
"zwilias/json-decode-exploration": "6.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"bburdette/toop": "1.0.1",
|
||||
"billstclair/elm-xml-eeue56": "1.0.3",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/random": "1.0.0",
|
||||
"fredcy/elm-parseint": "2.0.1",
|
||||
"j-maas/elm-ordered-containers": "1.0.0",
|
||||
"lukewestby/elm-string-interpolate": "1.0.4",
|
||||
"mgold/elm-nonempty-list": "4.2.0",
|
||||
"rtfeldman/elm-hex": "1.0.0"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
6
examples/todos/index.js
Normal file
6
examples/todos/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
load: function (elmLoaded) {},
|
||||
flags: function () {
|
||||
return null;
|
||||
},
|
||||
};
|
13
examples/todos/netlify.toml
Normal file
13
examples/todos/netlify.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[build]
|
||||
functions = "functions/"
|
||||
publish = "dist/"
|
||||
command = "mkdir bin && export PATH=\"/opt/build/repo/examples/smoothies/bin:$PATH\" && echo $PATH && curl https://static.lamdera.com/bin/linux/lamdera -o bin/lamdera && chmod a+x bin/lamdera && export ELM_HOME=\"$NETLIFY_BUILD_BASE/cache/elm\" && (cd ../../ && npm install --no-optional && npx --no-install elm-tooling install) && npm install && npm run generate:tailwind && npm run generate:graphql && npm run build"
|
||||
|
||||
[dev]
|
||||
command = "npm start"
|
||||
targetPort = 1234
|
||||
autoLaunch = true
|
||||
framework = "#custom"
|
||||
|
||||
[functions]
|
||||
included_files = ["content/**"]
|
6246
examples/todos/package-lock.json
generated
Normal file
6246
examples/todos/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
examples/todos/package.json
Normal file
29
examples/todos/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "elm-pages-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example site built with elm-pages.",
|
||||
"scripts": {
|
||||
"start": "elm-pages dev",
|
||||
"serve": "npm run build && http-server ./dist -a localhost -p 3000 -c-1",
|
||||
"build": "elm-pages build --debug --keep-cache",
|
||||
"generate:tailwind": "elm-tailwind-modules --dir ./gen --tailwind-config tailwind.config.js",
|
||||
"generate:graphql": "elm-graphql https://elm-pages-todos.hasura.app/v1/graphql --header \"x-hasura-admin-secret: $SMOOTHIES_HASURA_SECRET\" --output gen"
|
||||
},
|
||||
"author": "Dillon Kearns",
|
||||
"license": "BSD-3",
|
||||
"devDependencies": {
|
||||
"@dillonkearns/elm-graphql": "^4.2.3",
|
||||
"@netlify/functions": "^0.7.2",
|
||||
"@tailwindcss/forms": "^0.3.4",
|
||||
"busboy": "^1.1.0",
|
||||
"elm-pages": "file:../..",
|
||||
"elm-review": "^2.7.0",
|
||||
"elm-tailwind-modules": "^0.3.2",
|
||||
"elm-tooling": "^1.3.0",
|
||||
"postcss": "^8.4.5",
|
||||
"tailwindcss": "^2.2.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3"
|
||||
}
|
||||
}
|
28
examples/todos/port-data-source.ts
Normal file
28
examples/todos/port-data-source.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import kleur from "kleur";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
kleur.enabled = true;
|
||||
|
||||
export async function environmentVariable(name) {
|
||||
const result = process.env[name];
|
||||
if (result) {
|
||||
return result;
|
||||
} else {
|
||||
throw `No environment variable called ${kleur
|
||||
.yellow()
|
||||
.underline(name)}\n\nAvailable:\n\n${Object.keys(process.env)
|
||||
.slice(0, 5)
|
||||
.join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hello(name) {
|
||||
return `147 ${name}!!`;
|
||||
}
|
||||
export async function hashPassword(password) {
|
||||
return await bcrypt.hash(password, process.env.SMOOTHIES_SALT);
|
||||
}
|
||||
|
||||
function waitFor(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
BIN
examples/todos/public/favicon.ico
Normal file
BIN
examples/todos/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 450 B |
77
examples/todos/src/Data/User.elm
Normal file
77
examples/todos/src/Data/User.elm
Normal file
@ -0,0 +1,77 @@
|
||||
module Data.User exposing (User)
|
||||
|
||||
import Api.InputObject
|
||||
import Api.Mutation
|
||||
import Api.Object.Users
|
||||
import Api.Query
|
||||
import Api.Scalar exposing (Uuid(..))
|
||||
import Graphql.Operation exposing (RootQuery)
|
||||
import Graphql.OptionalArgument exposing (OptionalArgument(..))
|
||||
import Graphql.SelectionSet as SelectionSet exposing (SelectionSet)
|
||||
|
||||
|
||||
type alias User =
|
||||
{ email : String
|
||||
}
|
||||
|
||||
|
||||
|
||||
--selection : String -> SelectionSet User RootQuery
|
||||
--selection userId =
|
||||
-- Api.Query.users_by_pk { id = Uuid userId }
|
||||
-- (SelectionSet.map User
|
||||
-- Api.Object.Users.email
|
||||
-- )
|
||||
-- |> SelectionSet.nonNullOrFail
|
||||
--
|
||||
--
|
||||
--type alias LoginInfo =
|
||||
-- { userId : String }
|
||||
--
|
||||
--
|
||||
--login : { username : String, expectedPasswordHash : String } -> SelectionSet (Maybe Uuid) RootQuery
|
||||
--login { username, expectedPasswordHash } =
|
||||
-- Api.Query.users
|
||||
-- (\opts ->
|
||||
-- { opts
|
||||
-- | where_ =
|
||||
-- Present
|
||||
-- (Api.InputObject.buildUsers_bool_exp
|
||||
-- (\opt2 ->
|
||||
-- { opt2
|
||||
-- | username = Present (eq username)
|
||||
-- , password_hash = Present (eq expectedPasswordHash)
|
||||
-- }
|
||||
-- )
|
||||
-- )
|
||||
-- }
|
||||
-- )
|
||||
-- Api.Object.Users.id
|
||||
-- |> SelectionSet.map List.head
|
||||
--
|
||||
--
|
||||
--eq : String -> Api.InputObject.String_comparison_exp
|
||||
--eq str =
|
||||
-- Api.InputObject.buildString_comparison_exp (\opt -> { opt | eq_ = Present str })
|
||||
--
|
||||
--
|
||||
--updateUser : { userId : Uuid, name : String } -> SelectionSet () Graphql.Operation.RootMutation
|
||||
--updateUser { userId, name } =
|
||||
-- Api.Mutation.update_users_by_pk
|
||||
-- (\_ ->
|
||||
-- { set_ =
|
||||
-- Present
|
||||
-- (Api.InputObject.buildUsers_set_input
|
||||
-- (\optionals ->
|
||||
-- { optionals
|
||||
-- | name = Present name
|
||||
-- }
|
||||
-- )
|
||||
-- )
|
||||
-- }
|
||||
-- )
|
||||
-- { pk_columns =
|
||||
-- { id = userId }
|
||||
-- }
|
||||
-- SelectionSet.empty
|
||||
-- |> SelectionSet.nonNullOrFail
|
106
examples/todos/src/MySession.elm
Normal file
106
examples/todos/src/MySession.elm
Normal file
@ -0,0 +1,106 @@
|
||||
module MySession exposing (..)
|
||||
|
||||
import Codec
|
||||
import DataSource exposing (DataSource)
|
||||
import DataSource.Env as Env
|
||||
import Route
|
||||
import Server.Request exposing (Parser)
|
||||
import Server.Response as Response exposing (Response)
|
||||
import Server.Session as Session
|
||||
|
||||
|
||||
withSession :
|
||||
Parser request
|
||||
-> (request -> Result () (Maybe Session.Session) -> DataSource ( Session.Session, Response data errorPage ))
|
||||
-> Parser (DataSource (Response data errorPage))
|
||||
withSession =
|
||||
Session.withSession
|
||||
{ name = "mysession"
|
||||
, secrets = Env.expect "SESSION_SECRET" |> DataSource.map List.singleton
|
||||
, sameSite = "lax"
|
||||
}
|
||||
|
||||
|
||||
withSessionOrRedirect :
|
||||
Parser request
|
||||
-> (request -> Maybe Session.Session -> DataSource ( Session.Session, Response data errorPage ))
|
||||
-> Parser (DataSource (Response data errorPage))
|
||||
withSessionOrRedirect handler toRequest =
|
||||
Session.withSession
|
||||
{ name = "mysession"
|
||||
, secrets = Env.expect "SESSION_SECRET" |> DataSource.map List.singleton
|
||||
, sameSite = "lax"
|
||||
}
|
||||
handler
|
||||
(\request sessionResult ->
|
||||
sessionResult
|
||||
|> Result.map (toRequest request)
|
||||
|> Result.withDefault
|
||||
(DataSource.succeed
|
||||
( Session.empty
|
||||
, Route.redirectTo Route.Login
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
expectSessionOrRedirect :
|
||||
(request -> Session.Session -> DataSource ( Session.Session, Response data errorPage ))
|
||||
-> Parser request
|
||||
-> Parser (DataSource (Response data errorPage))
|
||||
expectSessionOrRedirect toRequest handler =
|
||||
Session.withSession
|
||||
{ name = "mysession"
|
||||
, secrets = Env.expect "SESSION_SECRET" |> DataSource.map List.singleton
|
||||
, sameSite = "lax"
|
||||
}
|
||||
handler
|
||||
(\request sessionResult ->
|
||||
sessionResult
|
||||
|> Result.map (Maybe.map (toRequest request))
|
||||
|> Result.withDefault Nothing
|
||||
|> Maybe.withDefault
|
||||
(DataSource.succeed
|
||||
( Session.empty
|
||||
, Route.redirectTo Route.Login
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
expectSessionDataOrRedirect :
|
||||
(Session.Session -> Maybe parsedSession)
|
||||
-> (parsedSession -> request -> Session.Session -> DataSource ( Session.Session, Response data errorPage ))
|
||||
-> Parser request
|
||||
-> Parser (DataSource (Response data errorPage))
|
||||
expectSessionDataOrRedirect parseSessionData handler toRequest =
|
||||
toRequest
|
||||
|> expectSessionOrRedirect
|
||||
(\parsedRequest session ->
|
||||
case parseSessionData session of
|
||||
Just parsedSession ->
|
||||
handler parsedSession parsedRequest session
|
||||
|
||||
Nothing ->
|
||||
DataSource.succeed
|
||||
( session
|
||||
, Route.redirectTo Route.Login
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
schema =
|
||||
{ name = ( "name", Codec.string )
|
||||
, message = ( "message", Codec.string )
|
||||
, user =
|
||||
( "user"
|
||||
, Codec.object User
|
||||
|> Codec.field "id" .id Codec.int
|
||||
|> Codec.buildObject
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type alias User =
|
||||
{ id : Int
|
||||
}
|
69
examples/todos/src/Request/Hasura.elm
Normal file
69
examples/todos/src/Request/Hasura.elm
Normal file
@ -0,0 +1,69 @@
|
||||
module Request.Hasura exposing (dataSource, mutationDataSource)
|
||||
|
||||
import DataSource exposing (DataSource)
|
||||
import DataSource.Env
|
||||
import DataSource.Http
|
||||
import Graphql.Document
|
||||
import Graphql.Operation exposing (RootMutation, RootQuery)
|
||||
import Graphql.SelectionSet exposing (SelectionSet)
|
||||
import Json.Encode as Encode
|
||||
import Time
|
||||
|
||||
|
||||
dataSource : SelectionSet value RootQuery -> DataSource value
|
||||
dataSource selectionSet =
|
||||
DataSource.Env.expect "SMOOTHIES_HASURA_SECRET"
|
||||
|> DataSource.andThen
|
||||
(\hasuraSecret ->
|
||||
DataSource.Http.uncachedRequest
|
||||
{ url = hasuraUrl
|
||||
, method = "POST"
|
||||
, headers = [ ( "x-hasura-admin-secret", hasuraSecret ) ]
|
||||
, body =
|
||||
DataSource.Http.jsonBody
|
||||
(Encode.object
|
||||
[ ( "query"
|
||||
, selectionSet
|
||||
|> Graphql.Document.serializeQuery
|
||||
|> Encode.string
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
(selectionSet
|
||||
|> Graphql.Document.decoder
|
||||
|> DataSource.Http.expectJson
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
mutationDataSource : SelectionSet value RootMutation -> DataSource value
|
||||
mutationDataSource selectionSet =
|
||||
DataSource.Env.expect "SMOOTHIES_HASURA_SECRET"
|
||||
|> DataSource.andThen
|
||||
(\hasuraSecret ->
|
||||
DataSource.Http.uncachedRequest
|
||||
{ url = hasuraUrl
|
||||
, method = "POST"
|
||||
, headers = [ ( "x-hasura-admin-secret", hasuraSecret ) ]
|
||||
, body =
|
||||
DataSource.Http.jsonBody
|
||||
(Encode.object
|
||||
[ ( "query"
|
||||
, selectionSet
|
||||
|> Graphql.Document.serializeMutation
|
||||
|> Encode.string
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
(selectionSet
|
||||
|> Graphql.Document.decoder
|
||||
|> DataSource.Http.expectJson
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
hasuraUrl : String
|
||||
hasuraUrl =
|
||||
"https://elm-pages-todos.hasura.app/v1/graphql"
|
23
examples/todos/src/Seo/Common.elm
Normal file
23
examples/todos/src/Seo/Common.elm
Normal file
@ -0,0 +1,23 @@
|
||||
module Seo.Common exposing (tags)
|
||||
|
||||
import Head exposing (Tag)
|
||||
import Head.Seo as Seo
|
||||
import Pages.Url
|
||||
|
||||
|
||||
tags : List Tag
|
||||
tags =
|
||||
Seo.summary
|
||||
{ canonicalUrlOverride = Nothing
|
||||
, siteName = "Ctrl-R Smoothies"
|
||||
, image =
|
||||
{ url = Pages.Url.external "https://images.unsplash.com/photo-1615478503562-ec2d8aa0e24e?ixlib=rb-1.2.1&raw_url=true&q=80&fm=jpg&crop=entropy&cs=tinysrgb&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1887"
|
||||
, alt = "Ctrl-R Smoothies Logo"
|
||||
, dimensions = Nothing
|
||||
, mimeType = Nothing
|
||||
}
|
||||
, description = "Browse our refreshing blended beverages!"
|
||||
, locale = Nothing
|
||||
, title = "Ctrl-R Smoothies"
|
||||
}
|
||||
|> Seo.website
|
561
examples/todos/style.css
Normal file
561
examples/todos/style.css
Normal file
@ -0,0 +1,561 @@
|
||||
nav {
|
||||
padding: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 26px;
|
||||
|
||||
}
|
||||
.checkout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border:rgba(47, 47, 47, 0.304) solid 1px;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* border:rgba(80, 80, 80, 0.704) solid 1px; */
|
||||
/* border-radius: 5px; */
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: #eee;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #b4b4b4;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
|
||||
color: #333;
|
||||
display: inline-block;
|
||||
font-size: .85em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: #929292 1px solid;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.item img {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 20px !important;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
margin: 20px 0;
|
||||
border: 0;
|
||||
border-top: 1px dashed #c5c5c5;
|
||||
border-bottom: 1px dashed #f7f7f7;
|
||||
}
|
||||
|
||||
.learn a {
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
color: #b83f45;
|
||||
}
|
||||
|
||||
.learn a:hover {
|
||||
text-decoration: underline;
|
||||
color: #787e7e;
|
||||
}
|
||||
|
||||
.learn h3,
|
||||
.learn h4,
|
||||
.learn h5 {
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.learn h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.learn h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.learn h5 {
|
||||
margin-bottom: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.learn ul {
|
||||
padding: 0;
|
||||
margin: 0 0 30px 25px;
|
||||
}
|
||||
|
||||
.learn li {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.learn p {
|
||||
font-size: 15px;
|
||||
font-weight: 300;
|
||||
line-height: 1.3;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#issue-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quote {
|
||||
border: none;
|
||||
margin: 20px 0 60px 0;
|
||||
}
|
||||
|
||||
.quote p {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quote p:before {
|
||||
content: '“';
|
||||
font-size: 50px;
|
||||
opacity: .15;
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.quote p:after {
|
||||
content: '”';
|
||||
font-size: 50px;
|
||||
opacity: .15;
|
||||
position: absolute;
|
||||
bottom: -42px;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.quote footer {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.quote footer img {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.quote footer a {
|
||||
margin-left: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.speech-bubble {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, .04);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.speech-bubble:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 30px;
|
||||
border: 13px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.learn-bar > .learn {
|
||||
position: absolute;
|
||||
width: 272px;
|
||||
top: 8px;
|
||||
left: -300px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(255, 255, 255, .6);
|
||||
transition-property: left;
|
||||
transition-duration: 500ms;
|
||||
}
|
||||
|
||||
@media (min-width: 899px) {
|
||||
.learn-bar {
|
||||
width: auto;
|
||||
padding-left: 300px;
|
||||
}
|
||||
|
||||
.learn-bar > .learn {
|
||||
left: 8px;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
line-height: 1.4em;
|
||||
background: #f5f5f5;
|
||||
color: #4d4d4d;
|
||||
min-width: 230px;
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-weight: 300;
|
||||
width: auto;
|
||||
|
||||
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todoapp {
|
||||
background: #fff;
|
||||
margin: 130px 0 40px 0;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
|
||||
0 25px 50px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* .todoapp input::-webkit-input-placeholder {
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
color: #e6e6e6;
|
||||
} */
|
||||
|
||||
/* .todoapp input::-moz-placeholder {
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.todoapp input::input-placeholder {
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
color: #e6e6e6;
|
||||
} */
|
||||
|
||||
.todoapp h1 {
|
||||
position: absolute;
|
||||
top: -155px;
|
||||
width: 100%;
|
||||
font-size: 100px;
|
||||
font-weight: 100;
|
||||
text-align: center;
|
||||
color: rgba(175, 47, 47, 0.15);
|
||||
-webkit-text-rendering: optimizeLegibility;
|
||||
-moz-text-rendering: optimizeLegibility;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.new-todo,
|
||||
.edit {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 24px;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: 1.4em;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 6px;
|
||||
border: 1px solid #999;
|
||||
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.new-todo {
|
||||
padding: 16px 16px 16px 60px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.003);
|
||||
box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.toggle-all {
|
||||
text-align: center;
|
||||
border: none; /* Mobile Safari */
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-all + label {
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
font-size: 0;
|
||||
position: absolute;
|
||||
top: -52px;
|
||||
left: -13px;
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.toggle-all + label:before {
|
||||
content: '❯';
|
||||
font-size: 22px;
|
||||
color: #e6e6e6;
|
||||
padding: 10px 27px 10px 27px;
|
||||
}
|
||||
|
||||
.toggle-all:checked + label:before {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.todo-list li {
|
||||
position: relative;
|
||||
font-size: 24px;
|
||||
border-bottom: 1px solid #ededed;
|
||||
}
|
||||
|
||||
.todo-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.todo-list li.editing {
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.todo-list li.editing .edit {
|
||||
display: block;
|
||||
width: 506px;
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 0 43px;
|
||||
}
|
||||
|
||||
.todo-list li.editing .view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-list li .toggle {
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
/* auto, since non-WebKit browsers doesn't support input styling */
|
||||
height: auto;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0;
|
||||
border: none; /* Mobile Safari */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.todo-list li .toggle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.todo-list li .toggle + label {
|
||||
/*
|
||||
Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
|
||||
IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
|
||||
*/
|
||||
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center left;
|
||||
}
|
||||
|
||||
.todo-list li .toggle:checked + label {
|
||||
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
|
||||
}
|
||||
|
||||
.todo-list li label {
|
||||
word-break: break-all;
|
||||
padding: 15px 15px 15px 60px;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
|
||||
.todo-list li.completed label {
|
||||
color: #d9d9d9;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.todo-list li .destroy {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: auto 0;
|
||||
font-size: 30px;
|
||||
color: #cc9a9a;
|
||||
margin-bottom: 11px;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
|
||||
.todo-list li .destroy:hover {
|
||||
color: #af5b5e;
|
||||
}
|
||||
|
||||
.todo-list li .destroy:after {
|
||||
content: '×';
|
||||
}
|
||||
|
||||
.todo-list li:hover .destroy {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.todo-list li .edit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-list li.editing:last-child {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: #777;
|
||||
padding: 10px 15px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.footer:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
|
||||
0 8px 0 -3px #f6f6f6,
|
||||
0 9px 1px -3px rgba(0, 0, 0, 0.2),
|
||||
0 16px 0 -6px #f6f6f6,
|
||||
0 17px 2px -6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
float: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.todo-count strong {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.filters li {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.filters li a {
|
||||
color: inherit;
|
||||
margin: 3px;
|
||||
padding: 3px 7px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.filters li a:hover {
|
||||
border-color: rgba(175, 47, 47, 0.1);
|
||||
}
|
||||
|
||||
.filters li a.selected {
|
||||
border-color: rgba(175, 47, 47, 0.2);
|
||||
}
|
||||
|
||||
.clear-completed,
|
||||
html .clear-completed:active {
|
||||
float: right;
|
||||
position: relative;
|
||||
line-height: 20px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-completed:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin: 65px auto 0;
|
||||
color: #bfbfbf;
|
||||
font-size: 10px;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info p {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/*
|
||||
Hack to remove background from Mobile Safari.
|
||||
Can't use it globally since it destroys checkboxes in Firefox
|
||||
*/
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
.toggle-all,
|
||||
.todo-list li .toggle {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.todo-list li .toggle {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.footer {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
5
examples/todos/tailwind.config.js
Normal file
5
examples/todos/tailwind.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
theme: {},
|
||||
variants: [],
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
Loading…
Reference in New Issue
Block a user