Add hacker news example.

This commit is contained in:
Dillon Kearns 2022-04-10 16:37:00 -07:00
parent 8800c8a16f
commit afcfdb72dc
23 changed files with 7717 additions and 0 deletions

8
examples/hackernews/.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,27 @@
module Api exposing (routes)
import ApiRoute exposing (ApiRoute)
import DataSource exposing (DataSource)
import Html exposing (Html)
import Pages.Manifest as Manifest
import Route exposing (Route)
import Site
routes :
DataSource (List Route)
-> (Html Never -> String)
-> List (ApiRoute.ApiRoute ApiRoute.Response)
routes getStaticRoutes htmlToString =
[ DataSource.succeed manifest |> Manifest.generator Site.canonicalUrl
]
manifest : Manifest.Config
manifest =
Manifest.init
{ name = "Hacker News Clone"
, description = "elm-pages port of Hacker News"
, startUrl = Route.Feed__ { feed = Nothing } |> Route.toPath
, icons = []
}

View File

@ -0,0 +1,88 @@
module Effect exposing (Effect(..), batch, fromCmd, map, none, perform)
import Browser.Navigation
import Http
import Url exposing (Url)
type Effect msg
= None
| Cmd (Cmd msg)
| Batch (List (Effect msg))
| FetchRouteData
{ body : Maybe { contentType : String, body : String }
, path : Maybe String
, toMsg : Result Http.Error Url -> 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)
FetchRouteData fetchInfo ->
FetchRouteData
{ body = fetchInfo.body
, path = fetchInfo.path
, toMsg = fetchInfo.toMsg >> fn
}
perform :
{ fetchRouteData :
{ body : Maybe { contentType : String, body : String }
, path : Maybe String
, toMsg : Result Http.Error Url -> 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)
FetchRouteData fetchInfo ->
helpers.fetchRouteData
{ body = fetchInfo.body
, path = fetchInfo.path
, toMsg = fetchInfo.toMsg
}

View File

@ -0,0 +1,84 @@
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 "Page not found. Maybe try another URL?" ]
, Html.div []
[ Html.button
[ onClick Increment
]
[ Html.text
(model.count
|> String.fromInt
)
]
]
]
]
, title = "This is a NotFound Error"
}
statusCode : ErrorPage -> number
statusCode error =
case error of
NotFound ->
404
InternalError _ ->
500

View File

@ -0,0 +1,176 @@
module Route.Feed__ exposing (Data, Model, Msg, route)
import DataSource exposing (DataSource)
import DataSource.Http
import Effect exposing (Effect)
import ErrorPage exposing (ErrorPage)
import Head
import Head.Seo as Seo
import Html
import Html.Attributes as Attr
import Json.Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (required)
import Pages.PageUrl exposing (PageUrl)
import Pages.Url
import Path exposing (Path)
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
import Server.Request as Request
import Server.Response as Response exposing (Response)
import Shared
import Story exposing (Story)
import Url.Builder
import View exposing (View)
type alias Model =
{}
type Msg
= NoOp
type alias RouteParams =
{ feed : Maybe String }
route : StatefulRoute RouteParams Data Model Msg
route =
RouteBuilder.serverRender
{ head = head
, data = data
}
|> RouteBuilder.buildWithLocalState
{ view = view
, update = update
, subscriptions = subscriptions
, init = init
}
init :
Maybe PageUrl
-> Shared.Model
-> StaticPayload Data RouteParams
-> ( Model, Effect Msg )
init maybePageUrl sharedModel static =
( {}, Effect.none )
update :
PageUrl
-> Shared.Model
-> StaticPayload Data 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
pages : DataSource (List RouteParams)
pages =
DataSource.succeed []
type alias Data =
{ stories : List Story }
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
data routeParams =
Request.queryParam "page"
|> Request.map
(\maybePage ->
let
feed : String
feed =
--const type = Astro.params.stories || "top";
case routeParams.feed |> Maybe.withDefault "top" of
"top" ->
"news"
"new" ->
"newest"
"show" ->
"show"
"ask" ->
"ask"
"job" ->
"jobs"
_ ->
"not-found"
getStoriesUrl : String
getStoriesUrl =
Url.Builder.crossOrigin "https://node-hnapi.herokuapp.com"
[ feed ]
[ Url.Builder.string "page" (maybePage |> Maybe.withDefault "1")
]
getStories : DataSource (List Story)
getStories =
DataSource.Http.get getStoriesUrl
(Story.decoder |> Json.Decode.list)
--("https://node-hnapi.herokuapp.com/"
-- ++ feed
-- ++ "?page="
--)
--get(`https://node-hnapi.herokuapp.com/${l}?page=${page}`);
in
getStories |> DataSource.map (\stories -> Response.render { stories = stories })
)
head :
StaticPayload Data 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
view :
Maybe PageUrl
-> Shared.Model
-> Model
-> StaticPayload Data RouteParams
-> View Msg
view maybeUrl sharedModel model static =
{ title = "News"
, body =
[ Html.main_
[ Attr.class "news-list"
]
[ static.data.stories
|> List.map Story.view
|> Html.ul []
--, Html.text <| "Count: " ++ String.fromInt (static.data.stories |> List.length)
]
]
}

View File

@ -0,0 +1,149 @@
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 [] (headerView :: pageView.body)
, title = pageView.title
}
headerView : Html msg
headerView =
Html.header
[ Attr.class "header"
]
[ Html.nav
[ Attr.class "inner"
]
[ Html.a
[ Attr.href "/"
]
[ Html.strong []
[ Html.text "HN" ]
]
, Html.a
[ Attr.href "/new"
]
[ Html.strong []
[ Html.text "New" ]
]
, Html.a
[ Attr.href "/show"
]
[ Html.strong []
[ Html.text "Show" ]
]
, Html.a
[ Attr.href "/ask"
]
[ Html.strong []
[ Html.text "Ask" ]
]
, Html.a
[ Attr.href "/job"
]
[ Html.strong []
[ Html.text "Jobs" ]
]
, Html.a
[ Attr.class "github"
, Attr.href "https://github.com/dillonkearns/elm-pages"
, Attr.target "_blank"
, Attr.rel "noreferrer"
]
[ Html.text "Built with elm-pages" ]
]
]

View File

@ -0,0 +1,23 @@
module Site exposing (canonicalUrl, config)
import DataSource exposing (DataSource)
import Head
import SiteConfig exposing (SiteConfig)
config : SiteConfig
config =
{ canonicalUrl = canonicalUrl
, head = head
}
canonicalUrl : String
canonicalUrl =
"https://elm-pages.com"
head : DataSource (List Head.Tag)
head =
[]
|> DataSource.succeed

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",
"NoRedInk/elm-json-decode-pipeline": "1.0.1",
"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/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.2",
"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,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/hackernews/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "elm-pages-example",
"version": "1.0.0",
"description": "Example site built with elm-pages.",
"scripts": {
"start": "elm-pages dev --debug",
"serve": "npm run build && http-server ./dist -a localhost -p 3000 -c-1",
"build": "elm-pages build --debug --keep-cache"
},
"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,105 @@
module Story exposing (..)
import Html exposing (Html)
import Html.Attributes as Attr
import Json.Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (optional, required)
type alias Story =
{ title : String
, points : Int
, user : String
, url : Maybe String
, domain : String
, time_ago : String
, comments_count : Int
, type_ : String
}
view : Story -> Html msg
view story =
Html.li
[ Attr.class "news-item"
]
[ Html.span
[ Attr.class "score"
]
[ Html.text (String.fromInt story.points) ]
, Html.span
[ Attr.class "title"
]
(case story.url of
Just url ->
[ Html.a
[ Attr.href url
, Attr.target "_blank"
, Attr.rel "noreferrer"
]
[ Html.text story.title ]
{-
{story.url && !story.url.startsWith("item?id=") ? (
<>
<a href={story.url} target="_blank" rel="noreferrer">
{story.title}
</a>
<span class="host"> ({story.domain})</span>
</>
) : (
<a href={`/item/${story.id}`}>{story.title}</a>
)}
-}
, Html.span [ Attr.class "host" ] [ Html.text <| " (" ++ story.domain ++ ")" ]
]
Nothing ->
[ Html.a
[-- TODO decode into custom type here? --Attr.href ("/item/" ++ story.id)
]
[]
]
)
, Html.br []
[]
, Html.span
[ Attr.class "meta"
]
[ Html.text "by "
, Html.a [ Attr.href "TODO" ]
[ Html.text story.user
]
, Html.text (" " ++ story.time_ago ++ " | ")
, Html.a
[-- TODO get story.id --Attr.href ("/stories/" ++ story.id)
]
[ if story.comments_count > 0 then
Html.text (String.fromInt story.comments_count ++ " comments")
else
Html.text "discuss"
]
]
, if story.type_ /= "link" then
Html.span
[ Attr.class "label"
]
[ Html.text story.type_ ]
else
Html.text ""
]
decoder : Decoder Story
decoder =
Json.Decode.succeed Story
|> required "title" Json.Decode.string
|> optional "points" Json.Decode.int 0
|> optional "user" Json.Decode.string ""
|> required "url" (Json.Decode.nullable Json.Decode.string)
|> optional "domain" Json.Decode.string ""
|> required "time_ago" Json.Decode.string
|> required "comments_count" Json.Decode.int
|> required "type" Json.Decode.string

View File

@ -0,0 +1,329 @@
/* Source: https://github.com/ryansolid/astro-solid-hackernews/blob/e6265ee3751dbd83a97f64c7de1e34af31a543ed/src/styles/global.css */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 15px;
background-color: #f2f3f5;
margin: 0;
padding-top: 55px;
color: #34495e;
overflow-y: scroll;
}
a {
color: #34495e;
text-decoration: none;
}
.header {
background-color: #4b158a;
position: fixed;
z-index: 999;
height: 55px;
top: 0;
left: 0;
right: 0;
}
.header .inner {
max-width: 800px;
box-sizing: border-box;
margin: 0 auto;
padding: 15px 5px;
}
.header a {
color: rgba(255, 255, 255, 0.8);
line-height: 24px;
transition: color 0.15s ease;
display: inline-block;
vertical-align: middle;
font-weight: 300;
letter-spacing: 0.075em;
margin-right: 1.8em;
}
.header a:hover {
color: #fff;
}
.header a.active {
color: #fff;
font-weight: 400;
}
.header a:nth-child(6) {
margin-right: 0;
}
.header .github {
color: #fff;
font-size: 0.9em;
margin: 0;
float: right;
}
.logo {
width: 24px;
margin-right: 10px;
display: inline-block;
vertical-align: middle;
}
.view {
max-width: 800px;
margin: 0 auto;
position: relative;
}
@media (max-width: 860px) {
.header .inner {
padding: 15px 30px;
}
}
@media (max-width: 600px) {
.header .inner {
padding: 15px;
}
.header a {
margin-right: 1em;
}
.header .github {
display: none;
}
}
.news-view {
padding-top: 45px;
}
.news-list,
.news-list-nav {
background-color: #fff;
border-radius: 2px;
}
.news-list-nav {
padding: 15px 30px;
position: fixed;
text-align: center;
top: 55px;
left: 0;
right: 0;
z-index: 998;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.news-list-nav .page-link {
margin: 0 1em;
}
.news-list-nav .disabled {
color: #aaa;
}
.news-list {
position: absolute;
margin: 30px 0;
width: 100%;
}
.news-list ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.slide-left-enter,
.slide-right-exit-to {
opacity: 0;
transform: translate(30px, 0);
}
.slide-left-exit-to,
.slide-right-enter {
opacity: 0;
transform: translate(-30px, 0);
}
@media (max-width: 600px) {
.news-list {
margin: 10px 0;
}
}
.news-item {
background-color: #fff;
padding: 20px 30px 20px 80px;
border-bottom: 1px solid #eee;
position: relative;
line-height: 20px;
}
.news-item .score {
color: #4b158a;
font-size: 1.1em;
font-weight: 700;
position: absolute;
top: 50%;
left: 0;
width: 80px;
text-align: center;
margin-top: -10px;
}
.news-item .host,
.news-item .meta {
font-size: 0.85em;
color: #626262;
}
.news-item .host a,
.news-item .meta a {
color: #626262;
text-decoration: underline;
}
.news-item .host a:hover,
.news-item .meta a:hover {
color: #4b158a;
}
.item-view-header {
background-color: #fff;
padding: 1.8em 2em 1em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.item-view-header h1 {
display: inline;
font-size: 1.5em;
margin: 0;
margin-right: 0.5em;
}
.item-view-header .host,
.item-view-header .meta,
.item-view-header .meta a {
color: #626262;
}
.item-view-header .meta a {
text-decoration: underline;
}
.item-view-comments {
background-color: #fff;
margin-top: 10px;
padding: 0 2em 0.5em;
}
.item-view-comments-header {
margin: 0;
font-size: 1.1em;
padding: 1em 0;
position: relative;
}
.item-view-comments-header .spinner {
display: inline-block;
margin: -15px 0;
}
.comment-children {
list-style-type: none;
padding: 0;
margin: 0;
}
@media (max-width: 600px) {
.item-view-header h1 {
font-size: 1.25em;
}
}
.comment-children .comment-children {
margin-left: 1.5em;
}
.comment {
border-top: 1px solid #eee;
position: relative;
}
.comment .by,
.comment .text,
.comment .toggle {
font-size: 0.9em;
margin: 1em 0;
}
.comment .by {
color: #626262;
}
.comment .by a {
color: #626262;
text-decoration: underline;
}
.comment .text {
overflow-wrap: break-word;
}
.comment .text a:hover {
color: #5f3392;
}
.comment .text pre {
white-space: pre-wrap;
}
.comment .toggle {
background-color: #fffbf2;
padding: 0.3em 0.5em;
border-radius: 4px;
}
.comment .toggle a {
color: #626262;
cursor: pointer;
}
.comment .toggle.open {
padding: 0;
background-color: transparent;
margin-bottom: -0.5em;
}
.user-view {
background-color: #fff;
box-sizing: border-box;
padding: 2em 3em;
}
.user-view h1 {
margin: 0;
font-size: 1.5em;
}
.user-view .meta {
list-style-type: none;
padding: 0;
}
.user-view .label {
display: inline-block;
min-width: 4em;
}
.user-view .about {
margin: 1em 0;
}
.user-view .links a {
text-decoration: underline;
}