Add smoothie app.

This commit is contained in:
Dillon Kearns 2022-05-24 16:57:24 -07:00
parent 56ee2cf145
commit 9fa5d41031
30 changed files with 8715 additions and 0 deletions

8
examples/smoothies/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
elm-stuff/
dist/
.cache/
.elm-pages/
functions/render/
functions/server-render/
gen/

View File

@ -0,0 +1,2 @@
v17.2.0

View File

@ -0,0 +1 @@
# README

View File

@ -0,0 +1,312 @@
import fs from "fs";
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.mjs");
fs.copyFileSync(
portsFilePath,
"./functions/server-render/port-data-source.mjs"
);
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
${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.mjs");
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("/")
);
}

View File

@ -0,0 +1,262 @@
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
]
greet : ApiRoute ApiRoute.Response
greet =
ApiRoute.succeed
(Server.Request.oneOf
[ Server.Request.expectFormPost
(\{ field, optionalField } ->
field "first"
)
, Server.Request.expectJsonBody (Json.Decode.field "first" Json.Decode.string)
, Server.Request.expectQueryParam "first"
, Server.Request.expectMultiPartFormPost
(\{ field, optionalField } ->
field "first"
)
]
|> Server.Request.map
(\firstName ->
Server.Response.plainText ("Hello " ++ firstName)
|> DataSource.succeed
)
)
|> ApiRoute.literal "api"
|> ApiRoute.slash
|> ApiRoute.literal "greet"
|> ApiRoute.serverRender
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 = []
}

View File

@ -0,0 +1,128 @@
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)
| 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
}
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
}
-> Effect pageMsg
-> Cmd msg
perform ({ fromPageMsg, key } as helpers) effect =
case effect of
None ->
Cmd.none
Cmd cmd ->
Cmd.map fromPageMsg cmd
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

View File

@ -0,0 +1,74 @@
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 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 "Looks like you've wandered off the trail. Try finding a new path." ]
, Html.div [] []
]
]
, title = "This is a NotFound Error"
}
statusCode : ErrorPage -> number
statusCode error =
case error of
NotFound ->
404
InternalError _ ->
500

View File

@ -0,0 +1,179 @@
module Route.Index exposing (ActionData, Data, Model, Msg, route)
import Api.Object.Products
import Api.Query
import Api.Scalar exposing (Uuid)
import DataSource exposing (DataSource)
import Effect exposing (Effect)
import ErrorPage exposing (ErrorPage)
import Graphql.SelectionSet as SelectionSet
import Head
import Head.Seo as Seo
import Html
import Html.Attributes as Attr
import Icon
import Pages.Msg
import Pages.PageUrl exposing (PageUrl)
import Pages.Url
import Path exposing (Path)
import Request.Hasura
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
import Server.Request as Request
import Server.Response as Response exposing (Response)
import Shared
import Time
import View exposing (View)
type alias Model =
{}
type Msg
= NoOp
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
}
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
type alias Data =
{ smoothies : List Smoothie }
type alias ActionData =
{}
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
data routeParams =
Request.requestTime
|> Request.map
(\requestTime ->
Request.Hasura.dataSource (requestTime |> Time.posixToMillis |> String.fromInt)
smoothiesSelection
|> DataSource.map (\products -> Response.render (Data products))
)
type alias Smoothie =
{ name : String
, id : Uuid
, description : String
, price : Int
, unsplashImage : String
}
smoothiesSelection =
Api.Query.products identity
(SelectionSet.map5 Smoothie
Api.Object.Products.name
Api.Object.Products.id
Api.Object.Products.description
Api.Object.Products.price
Api.Object.Products.unsplash_image_id
)
action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage))
action routeParams =
Request.skip "No action."
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 = "Browse our refreshing blended beverages!"
, locale = Nothing
, title = "Ctrl-R Smoothies"
}
|> Seo.website
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 =
[ app.data.smoothies
|> List.map productView
|> Html.ul []
]
}
productView : Smoothie -> Html.Html msg
productView item =
Html.li [ Attr.class "item" ]
[ Html.div []
[ Html.h3 [] [ Html.text item.name ]
, Html.p [] [ Html.text item.description ]
]
, Html.div []
[ Html.img
[ Attr.src
(item.unsplashImage
++ "?ixlib=rb-1.2.1&raw_url=true&q=80&fm=jpg&crop=entropy&cs=tinysrgb&auto=format&fit=crop&w=600&h=903"
)
, Attr.width 150
]
[]
]
]

View File

@ -0,0 +1,146 @@
module Route.Login exposing (ActionData, Data, Model, Msg, route)
import DataSource exposing (DataSource)
import Dict exposing (Dict)
import ErrorPage exposing (ErrorPage)
import Head
import Head.Seo as Seo
import Html
import Html.Attributes as Attr
import MySession
import Pages.Msg
import Pages.PageUrl exposing (PageUrl)
import Pages.Url
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 =
\_ ->
MySession.withSession
(Request.expectFormPost (\{ field } -> field "name"))
(\name session ->
( session
|> Result.withDefault Nothing
|> Maybe.withDefault Session.empty
|> Session.insert "userId" name
|> Session.withFlash "message" ("Welcome " ++ name ++ "!")
, Route.redirectTo Route.Index
)
|> DataSource.succeed
)
}
|> RouteBuilder.buildNoState { view = view }
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 "name"
|> Data
|> Server.Response.render
)
|> DataSource.succeed
_ ->
( Session.empty
, { username = Nothing }
|> Server.Response.render
)
|> DataSource.succeed
)
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 =
{}
view :
Maybe PageUrl
-> Shared.Model
-> StaticPayload Data ActionData RouteParams
-> View (Pages.Msg.Msg Msg)
view maybeUrl sharedModel static =
{ title = "Login"
, body =
[ Html.p []
[ Html.text
(case static.data.username of
Just username ->
"Hello " ++ username ++ "!"
Nothing ->
"You aren't logged in yet."
)
]
, Html.form
[ Attr.method "post"
, Attr.action "/login"
]
[ Html.label [] [ Html.input [ Attr.name "name", Attr.type_ "text" ] [] ]
, Html.button
[ Attr.type_ "submit"
]
[ Html.text "Login" ]
]
]
}

View File

@ -0,0 +1,226 @@
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.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
]

View File

@ -0,0 +1,120 @@
module Shared exposing (Data, Model, Msg(..), SharedMsg(..), template)
import DataSource
import Effect exposing (Effect)
import Html exposing (Html)
import Html.Attributes as Attr
import Icon
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
}

View 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 })

View 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 ]
}

View File

@ -0,0 +1,8 @@
{
"name": "dmy/elm-doc-preview",
"summary": "Offline documentation previewer",
"version": "5.0.0",
"exposed-modules": [
"Page"
]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import adapter from "./adapter.mjs";
export default {
vite: defineConfig({}),
adapter,
};

View 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"
}
}

View File

@ -0,0 +1,67 @@
{
"type": "application",
"source-directories": [
"src",
"app",
"../../src",
".elm-pages",
"../../plugins",
"gen"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"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": {}
}
}

View File

@ -0,0 +1,14 @@
exports.handler =
/**
* @param {import('aws-lambda').APIGatewayProxyEvent} event
* @param {any} context
*/
async function (event, context) {
return {
body: JSON.stringify(new Date().toTimeString()),
headers: {
"Content-Type": "application/json",
},
statusCode: 200,
};
};

View File

@ -0,0 +1,6 @@
export default {
load: function (elmLoaded) {},
flags: function () {
return null;
},
};

View File

@ -0,0 +1,13 @@
[build]
functions = "functions/"
publish = "dist/"
command = "mkdir bin && export PATH=\"/opt/build/repo/examples/pokedex/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 && cp secret-note.txt functions/server-render/"
[dev]
command = "npm start"
targetPort = 1234
autoLaunch = true
framework = "#custom"
[functions]
included_files = ["content/**"]

6233
examples/smoothies/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"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://smoothie-shop.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"
}
}

View File

@ -0,0 +1,23 @@
import kleur from "kleur";
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}!!`;
}
function waitFor(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

View File

@ -0,0 +1,43 @@
pre.elmsh {
padding: 10px;
margin: 0;
text-align: left;
overflow: auto;
padding: 20px !important;
}
code.elmsh {
padding: 0;
}
code {
font-family: 'Roboto Mono' !important;
font-size: 20px !important;
line-height: 28px;
}
.elmsh-line:before {
/* content: attr(data-elmsh-lc); */
display: inline-block;
text-align: right;
width: 40px;
padding: 0 20px 0 0;
opacity: 0.3;
}
.elmsh {
color: #f8f8f2;
background: #000;
}
.elmsh-hl {background: #343434;}
.elmsh-add {background: #003800;}
.elmsh-del {background: #380000;}
.elmsh-comm {color: #75715e;}
.elmsh1 {color: #ae81ff;}
.elmsh2 {color: #e6db74;}
.elmsh3 {color: #66d9ef;}
.elmsh4 {color: #f92672;}
.elmsh5 {color: #a6e22e;}
.elmsh6 {color: #ae81ff;}
.elmsh7 {color: #fd971f;}

View File

@ -0,0 +1,22 @@
module Icon exposing (refresh)
import Html exposing (Html)
import Svg exposing (path, svg)
import Svg.Attributes as SvgAttr
refresh =
svg
[ SvgAttr.class "h-6 w-6"
, SvgAttr.fill "none"
, SvgAttr.viewBox "0 0 24 24"
, SvgAttr.stroke "currentColor"
, SvgAttr.strokeWidth "2"
]
[ path
[ SvgAttr.strokeLinecap "round"
, SvgAttr.strokeLinejoin "round"
, SvgAttr.d "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
]
[]
]

View File

@ -0,0 +1,85 @@
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
)
)
)
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
}

View File

@ -0,0 +1,74 @@
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
dataSource : String -> SelectionSet value RootQuery -> DataSource value
dataSource timeStamp selectionSet =
DataSource.Env.expect "SMOOTHIES_HASURA_SECRET"
|> DataSource.andThen
(\hasuraSecret ->
DataSource.Http.request
{ url =
hasuraUrl
-- for now, this timestamp invalidates the dev server cache
-- it would be helpful to have a way to mark a DataSource as uncached. Maybe only allow
-- from server-rendered pages?
++ "?time="
++ timeStamp
, 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 : String -> SelectionSet value RootMutation -> DataSource value
mutationDataSource timeStamp selectionSet =
DataSource.Env.expect "SMOOTHIES_HASURA_SECRET"
|> DataSource.andThen
(\hasuraSecret ->
DataSource.Http.request
{ url = hasuraUrl ++ "?time=" ++ timeStamp
, 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://smoothie-shop.hasura.app/v1/graphql"

View File

@ -0,0 +1,553 @@
nav {
padding: 20px;
font-weight: bold;
font-size: 26px;
}
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;
}
.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;
}
button {
margin: 0;
padding: 0;
border: 0;
background: none;
font-size: 100%;
vertical-align: baseline;
font-family: inherit;
font-weight: inherit;
color: inherit;
-webkit-appearance: none;
appearance: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
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;
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
theme: {},
variants: [],
plugins: [require("@tailwindcss/forms")],
};