Compare commits
5 Commits
a6353699df
...
727d522e3a
Author | SHA1 | Date |
---|---|---|
Dmitriy Pleshevskiy | 727d522e3a | |
Dmitriy Pleshevskiy | 6b9f07a6a5 | |
Dmitriy Pleshevskiy | 2822a45dee | |
Dmitriy Pleshevskiy | 9912188db1 | |
Dmitriy Pleshevskiy | b918557791 |
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
|
||||
!/makefile
|
||||
!/*ignore
|
||||
|
||||
!/Dockerfile
|
||||
|
||||
!/*json
|
||||
!/*.ts
|
||||
|
||||
!/(domain|repo|uikit|comp|views|translates)/*.ts
|
||||
!/styles/*.scss
|
||||
|
|
@ -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"]
|
|
@ -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", [], [
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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" } },
|
||||
);
|
||||
|
||||
|
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
]);
|
||||
}
|
|
@ -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),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue