mirror of
https://github.com/dillonkearns/elm-pages-v3-beta.git
synced 2024-12-24 12:22:27 +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