Compare commits

...

5 Commits

Author SHA1 Message Date
Dmitriy Pleshevskiy 727d522e3a web: use scss instead of css 2022-05-29 22:55:01 +03:00
Dmitriy Pleshevskiy 6b9f07a6a5 web: add docker file 2022-05-29 14:37:46 +03:00
Dmitriy Pleshevskiy 2822a45dee web: add e500 error
web: refac prefix for lang url
2022-05-29 01:56:22 +03:00
Dmitriy Pleshevskiy 9912188db1 web: add dropdown to change langs 2022-05-29 01:31:14 +03:00
Dmitriy Pleshevskiy b918557791 add translates 2022-05-28 23:28:07 +03:00
18 changed files with 224 additions and 205 deletions

13
web/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
/*
!/makefile
!/*ignore
!/Dockerfile
!/*json
!/*.ts
!/(domain|repo|uikit|comp|views|translates)/*.ts
!/styles/*.scss

13
web/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM denoland/deno:alpine-1.22.1
EXPOSE 33334
WORKDIR /app
USER deno
ADD . .
# Compile the main app so that it doesn't need to be compiled each startup/entry.
RUN deno cache server.ts
CMD ["run", "-A", "server.ts"]

View File

@ -1,14 +1,15 @@
import { AnyNode, E } from "ren/node.ts";
import { Context } from "../context.ts";
export function Layout(page: AnyNode): AnyNode {
return E("html", { lang: "ru" }, [
export function Layout(ctx: Context, page: AnyNode): AnyNode {
return E("html", { lang: ctx.lang }, [
E("head", [], [
E("meta", { charset: "utf-8" }),
E("meta", {
name: "viewport",
content: "width=device-width, initial-scale=1",
}),
E("link", { rel: "stylesheet", href: "/static/styles.css" }),
E("link", { rel: "stylesheet", href: "/styles/main.css" }),
E("title", [], "Recipes"),
]),
E("body", [], [

View File

@ -1,12 +1,12 @@
import { AnyNode, Attrs, E, Elem } from "ren/node.ts";
import { classNames } from "ren/attrs.ts";
import { Context } from "../context.ts";
import { Context, getLangHref, iterLangs, Lang } from "../context.ts";
export function PageLayout(ctx: Context, children: AnyNode[]): Elem {
return E("div", { id: "main" }, [
Header(ctx),
E("div", classNames("content"), children),
Footer(),
Footer(ctx),
]);
}
@ -18,25 +18,27 @@ export function Header(ctx: Context): AnyNode {
export function HeaderNav(ctx: Context): AnyNode {
return E("nav", classNames("main-menu"), [
Link("Главная", navLink("/", ctx)),
Link("Рецепты", navLink("/recipes", ctx)),
Link("Ингредиенты", navLink("/ingredients", ctx)),
Link(ctx.tr.Home, navLink("/", ctx)),
Link(ctx.tr.Recipes, navLink("/recipes", ctx)),
Link(ctx.tr.Ingredients, navLink("/ingredients", ctx)),
]);
}
function navLink(href: string, ctx?: Context): Attrs {
const attrs: Attrs = { href };
if (ctx?.locPath === href) attrs["aria-current"] = "true";
function navLink(lhref: string, ctx?: Context): Attrs {
const attrs: Attrs = { lhref };
if (ctx?.locPath === lhref) attrs["aria-current"] = "true";
return attrs;
}
export function Footer(): AnyNode {
return E("footer", classNames("footer gap-v-1x5"), [
E("div", classNames("content-width"), [
Link("Исходный код", {
export function Footer(ctx: Context): AnyNode {
return E("footer", classNames("footer"), [
E("div", classNames("content-width row-sta-bet"), [
Link(ctx.tr.Source_code, {
target: "_blank",
href: "https://notabug.org/pleshevskiy/recipes",
rel: "external nofollow noopener noreferrer",
}),
ChangeLang(ctx),
]),
]);
}
@ -44,3 +46,22 @@ export function Footer(): AnyNode {
export function Link(text: string, attrs: Attrs | Attrs[]): AnyNode {
return E("a", attrs, text);
}
export function ChangeLang(ctx: Context): AnyNode {
const dropdownId = "change_langs";
return E("div", classNames("dropdown"), [
E("input", { id: dropdownId, type: "checkbox" }),
E("label", { for: dropdownId }, ctx.lang),
E(
"ul",
[],
iterLangs().filter((l) => l !== ctx.lang).map((l) =>
ChangeLangBtn(ctx, l)
),
),
]);
}
export function ChangeLangBtn(ctx: Context, lang: Lang): AnyNode {
return E("a", { "href": getLangHref(lang, ctx.locPath) }, lang);
}

View File

@ -1,3 +1,24 @@
import { Translations } from "./translates/rus.ts";
export interface Context {
locPath: string;
lang: Lang;
tr: Translations;
}
export function getLangHref(lang: Lang, url: string): string {
return getLangUrlPrefix(lang) + url;
}
export function getLangUrlPrefix(lang: Lang): string {
return lang === Lang.Rus ? "" : `/${lang}`;
}
export function iterLangs(): Lang[] {
return [Lang.Eng, Lang.Rus];
}
export enum Lang {
Rus = "rus",
Eng = "eng",
}

View File

@ -1,5 +1,6 @@
{
"imports": {
"ren/": "https://notabug.org/pleshevskiy/ren/raw/v2/ren/"
"ren/": "https://notabug.org/pleshevskiy/ren/raw/v2/ren/",
"ren/": "../../ren/ren/"
}
}

View File

@ -3,10 +3,13 @@ DOCKER_NAME := recipes
DOCKER_TAG := recipes
watch:
${PAR} deno-w sass-w
deno-w:
deno run -A --watch server.ts
start:
deno run -A server.ts
sass-w:
sass -w styles/main.scss public/styles/main.css
docker-restart: docker-stop docker-run

View File

@ -1,11 +1,15 @@
import { Lang } from "../../context.ts";
import { Ingredient } from "../../domain/ingredient/types.ts";
import { RestLang } from "../misc_types.ts";
import { IngredientRepo } from "./types.ts";
export class RestIngredientRepo implements IngredientRepo {
async fetchIngredients(): Promise<Ingredient[]> {
async fetchIngredients(lang: Lang): Promise<Ingredient[]> {
const url = new URL("http://localhost:33333/api/ingredients");
url.searchParams.set("lang", lang);
const res = await fetch(
"http://localhost:33333/api/ingredients",
url.toString(),
{ headers: { "content-type": "application/json" } },
);

View File

@ -1,5 +1,6 @@
import { Lang } from "../../context.ts";
import { Ingredient } from "../../domain/ingredient/types.ts";
export interface IngredientRepo {
fetchIngredients(): Promise<Ingredient[]>;
fetchIngredients(lang: Lang): Promise<Ingredient[]>;
}

View File

@ -1,12 +1,15 @@
import { StrRenderer } from "ren/str.ts";
import { Layout } from "./comp/layout.ts";
import { Context } from "./context.ts";
import { Context, getLangHref, Lang } from "./context.ts";
import { E404Page } from "./views/e404.ts";
import * as log from "./log.ts";
import { HomePage } from "./views/home.ts";
import { RecipesPage } from "./views/recipes.ts";
import { IngredientsPage } from "./views/ingredients.ts";
import { RestIngredientRepo } from "./repo/ingredient/rest.ts";
import rusTranslates from "./translates/rus.ts";
import type { Translations } from "./translates/rus.ts";
import { E500Page } from "./views/e500.ts";
if (import.meta.main) {
await main();
@ -39,38 +42,92 @@ async function serveHttp(conn: Deno.Conn) {
}
async function handleRequest(req: Request): Promise<Response> {
log.debug({ url: req.url });
log.info({ method: req.method, url: req.url });
if (req.method === "GET") {
return await handleGet(req);
} else {
return new Response("Method Not Allowed", { status: 405 });
}
}
async function handleGet(req: Request) {
const ctx = createContextFromRequest(req);
try {
const res = await tryCreateFileResponse(ctx.locPath);
return res;
} catch (_) {
const ren = new StrRenderer({ wrapNode: Layout });
if (ctx.lang !== Lang.Rus) {
await loadAndUpdateTranslations(ctx);
}
log.debug({ context: ctx });
if (ctx.locPath === "/") {
return createHtmlResponse(ren.render(HomePage(ctx)));
} else if (ctx.locPath === "/recipes") {
return createHtmlResponse(ren.render(RecipesPage(ctx)));
} else if (ctx.locPath === "/ingredients") {
const repo = new RestIngredientRepo();
const ingredients = await repo.fetchIngredients();
const ren = new StrRenderer({
wrapNode: Layout.bind(null, ctx),
onVisitAttr: ([key, value]) => {
if (key === "lhref" && typeof value === "string") {
return ["href", getLangHref(ctx.lang, value)];
} else {
return [key, value];
}
},
});
return createHtmlResponse(
ren.render(IngredientsPage(ctx, { ingredients })),
);
} else {
return createHtmlResponse(ren.render(E404Page(ctx)), 404);
try {
if (ctx.locPath === "/") {
return createHtmlResponse(ren.render(HomePage(ctx)));
} else if (ctx.locPath === "/recipes") {
return createHtmlResponse(ren.render(RecipesPage(ctx)));
} else if (ctx.locPath === "/ingredients") {
const repo = new RestIngredientRepo();
const ingredients = await repo.fetchIngredients(ctx.lang);
return createHtmlResponse(
ren.render(IngredientsPage(ctx, { ingredients })),
);
} else {
return createHtmlResponse(ren.render(E404Page(ctx)), 404);
}
} catch (_) {
return createHtmlResponse(ren.render(E500Page(ctx)), 500);
}
}
}
async function loadAndUpdateTranslations(ctx: Context) {
try {
const translates = await import(`./translates/${ctx.lang}.ts`);
ctx.tr = Object.entries(translates.default as Partial<Translations>)
.reduce(
(acc, [key, val]) => ({
...acc,
[key as keyof Translations]: val,
}),
{ ...ctx.tr } as Translations,
);
} catch (_e) { /* ignore */ }
}
function createContextFromRequest(req: Request): Context {
const locUrl = new URL(req.url);
const lang = langFromUrl(locUrl);
return {
locPath: new URL(req.url).pathname,
lang,
locPath: stripPrefix(`/${lang}`, locUrl.pathname),
tr: rusTranslates,
};
}
function langFromUrl(url: URL): Lang {
return url.pathname.startsWith("/eng/") ? Lang.Eng : Lang.Rus;
}
function stripPrefix(prefix: string, val: string): string {
return val.startsWith(prefix) ? val.slice(prefix.length) : val;
}
function createHtmlResponse(body: string, status = 200): Response {
return new Response(body, {
status,
@ -80,6 +137,7 @@ function createHtmlResponse(body: string, status = 200): Response {
async function tryCreateFileResponse(urlPath: string): Promise<Response> {
const filePath = extractFilePath(urlPath);
log.debug({ filePath });
if (!filePath) throw new SkipFile();
const content = await Deno.readTextFile(filePath).catch(() => {
@ -98,8 +156,9 @@ function createFileResponse(content: string, fileExt: string): Response {
}
function extractFilePath(urlPath: string): string | null {
if (urlPath.startsWith("/static")) {
return urlPath.slice(1);
const relPath = urlPath.slice(1);
if (relPath.startsWith("styles/")) {
return `public/${relPath}`;
}
return null;
}

View File

@ -1,155 +0,0 @@
:root {
--min-width: 320px;
--max-width: 1024px;
--default-color-white: #ffffff;
--color-blue: #1966df;
--color-gray: #ccc;
--default-font-size: 16px;
--font-family: system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto,Oxygen-Sans, Ubuntu, Cantarell, "Segoe UI", Verdana, sans-serif;
--font-weight-regular: 400;
}
*, *::before, *::after {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
background-color: var(--default-color-white);
font-size: var(--default-font-size);
line-height: 1;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
body {
font-weight: var(--font-weight-regular);
font-family: var(--font-family);
min-width: var(--min-width);
}
html, body {
height: 100%;
width: 100%;
}
ul, ol {
list-style: none;
}
#root {
min-height: 100vh;
align-items: stretch;
}
#root,
#main {
display: flex;
flex-direction: column;
}
#main,
.content {
flex: 1 0;
}
.gap-v-1x5 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.footer {
border-top: solid 1px var(--color-gray);
}
.main-menu {
display: flex;
flex-direction: row;
}
// anim
.main-menu > a {
transition: all .2s ease-in-out;
}
.main-menu > a {
color: var(--color-blue);
padding: 0.5rem;
border-radius: 6px;
border: 1px solid var(--color-blue);
text-decoration: none;
}
.main-menu > a:hover,
.main-menu > a[aria-current]:not([aria-current=""]) {
color: var(--default-color-white);
background-color: var(--color-blue);
}
.main-menu > a:not(:last-child) {
margin-right: 1rem;
}
.content-width {
width: 100%;
max-width: var(--max-width);
margin-left: auto;
margin-right: auto;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.responsive-typography h3 {
font-size: 24px;
}
.responsive-typography > div,
.responsive-typography p,
.responsive-typography li {
font-size: 18px;
line-height: 1.5;
}
.responsive-typography ul,
.responsive-typography ol {
width: 100%;
}
.responsive-typography ul li,
.responsive-typography ol li {
position: relative;
width: 100%;
min-height: 1.5rem;
padding-left: 1.5rem;
}
.responsive-typography ul li::before,
.responsive-typography ol li::before {
content: '';
position: absolute;
top: 0;
left: 0;
}
.responsive-typography ul > li::before {
background-color: var(--color-blue);
border-radius: 50%;
width: 0.5rem;
height: 0.5rem;
margin-top: 0.5rem;
margin-left: 0.25rem;
}
.responsive-typography > * + * {
margin-top: 2rem;
}
.responsive-typography > div + div,
.responsive-typography p + p {
margin-top: 1rem;
}
.responsive-typography li + li {
margin-top: 0.5rem;
}

10
web/translates/eng.ts Normal file
View File

@ -0,0 +1,10 @@
import { Translations } from "./rus.ts";
export default {
Home: "Home",
Recipes: "Recipes",
Ingredients: "Ingredients",
Source_code: "Source code",
Page_not_found: "Page not found",
Internal_server_error: "Internal server error",
} as Translations;

12
web/translates/rus.ts Normal file
View File

@ -0,0 +1,12 @@
export const rus = {
Home: "Главная",
Recipes: "Рецепты",
Ingredients: "Ингредиенты",
Source_code: "Исходный код",
Page_not_found: "Страница не найдена",
Internal_server_error: "Внутренняя ошибка сервера",
};
export default rus;
export type Translations = typeof rus;

View File

@ -5,11 +5,11 @@ import { Context } from "../context.ts";
import { H3 } from "../uikit/typo.ts";
export function E404Page(ctx: Context): AnyNode {
return PageLayout(ctx, [E404()]);
return PageLayout(ctx, [E404(ctx)]);
}
export function E404(): AnyNode {
return E("div", classNames("content-width"), [
H3("Страница не найдена"),
export function E404(ctx: Context): AnyNode {
return E("div", classNames("content-width gap-v-1x5"), [
H3(ctx.tr.Page_not_found),
]);
}

15
web/views/e500.ts Normal file
View File

@ -0,0 +1,15 @@
import { PageLayout } from "../comp/page_layout.ts";
import { AnyNode, E } from "ren/node.ts";
import { classNames } from "ren/attrs.ts";
import { Context } from "../context.ts";
import { H3 } from "../uikit/typo.ts";
export function E500Page(ctx: Context): AnyNode {
return PageLayout(ctx, [E500(ctx)]);
}
export function E500(ctx: Context): AnyNode {
return E("div", classNames("content-width gap-v-1x5"), [
H3(ctx.tr.Internal_server_error),
]);
}

View File

@ -6,8 +6,8 @@ import { H3 } from "../uikit/typo.ts";
export function HomePage(ctx: Context): AnyNode {
return PageLayout(ctx, [
E("div", classNames("content-width"), [
H3("Главная"),
E("div", classNames("content-width gap-v-1x5"), [
H3(ctx.tr.Home),
]),
]);
}

View File

@ -14,8 +14,8 @@ export function IngredientsPage(
data: IngredientsPageData,
): AnyNode {
return PageLayout(ctx, [
E("div", classNames("content-width"), [
H3("Ингредиенты"),
E("div", classNames("content-width gap-v-1x5"), [
H3(ctx.tr.Ingredients),
IngredientList(data.ingredients),
]),
]);
@ -23,7 +23,7 @@ export function IngredientsPage(
export function IngredientList(ingrs: Ingredient[]): AnyNode {
return E("div", classNames("responsive-typography"), [
E("ul", classNames("gap-v-1x5"), ingrs.map(IngredientItem)),
E("ul", [], ingrs.map(IngredientItem)),
]);
}

View File

@ -6,8 +6,8 @@ import { H3 } from "../uikit/typo.ts";
export function RecipesPage(ctx: Context): AnyNode {
return PageLayout(ctx, [
E("div", classNames("content-width"), [
H3("Рецепты"),
E("div", classNames("content-width gap-v-1x5"), [
H3(ctx.tr.Recipes),
]),
]);
}