Add starting point for todos app example.

This commit is contained in:
Dillon Kearns 2022-08-10 15:24:15 -07:00
parent 62719e97b2
commit ea03083bf2
29 changed files with 8846 additions and 0 deletions

8
examples/todos/.gitignore vendored Normal file
View 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
View File

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

1
examples/todos/README.md Normal file
View File

@ -0,0 +1 @@
# README

316
examples/todos/adapter.mjs Normal file
View 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
View 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 = []
}

View 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

View 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

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

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

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

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

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

68
examples/todos/elm.json Normal file
View 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
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/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

File diff suppressed because it is too large Load Diff

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

View 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

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

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

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

View File

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