feat: correctly cache images with redis
This commit is contained in:
parent
1917fc7d8f
commit
af8adf9ce7
29
components/Card.tsx
Normal file
29
components/Card.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Recipe } from "../lib/recipes.ts";
|
||||
|
||||
export function Card(
|
||||
{ link, title, image }: { link?: string; title?: string; image?: string },
|
||||
) {
|
||||
return (
|
||||
<a
|
||||
href={link}
|
||||
style={{
|
||||
backgroundImage: `url(${image})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
|
||||
lg:w-56 lg:h-56
|
||||
sm:w-40 sm:h-40
|
||||
w-32 h-32"
|
||||
>
|
||||
<div class="h-full flex flex-col justify-between relative z-10">
|
||||
<div>
|
||||
{/* Recipe Card content */}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
|
||||
</a>
|
||||
);
|
||||
}
|
@ -1,29 +1,18 @@
|
||||
import { Recipe } from "../lib/recipes.ts";
|
||||
import { Card } from "@components/Card.tsx";
|
||||
import { Recipe } from "@lib/recipes.ts";
|
||||
|
||||
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe;
|
||||
|
||||
const imageUrl = image.startsWith("Recipes/images/")
|
||||
? `/api/images?image=${image}&width=200&height=200`
|
||||
: image;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/recipes/${recipe.id}`}
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
recipe?.meta?.image || "/api/recipes/images/placeholder.jpg"
|
||||
}?width=200&height=200)`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
|
||||
lg:w-56 lg:h-56
|
||||
sm:w-40 sm:h-40
|
||||
w-32 h-32"
|
||||
>
|
||||
<div class="h-full flex flex-col justify-between relative z-10">
|
||||
<div>
|
||||
{/* Recipe Card content */}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{recipe.name}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
|
||||
</a>
|
||||
<Card
|
||||
title={recipe.name}
|
||||
image={imageUrl}
|
||||
link={`/recipes/${recipe.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { Recipe } from "../lib/recipes.ts";
|
||||
import { Recipe } from "@lib/recipes.ts";
|
||||
|
||||
export function RecipeHero({ recipe }: { recipe: Recipe }) {
|
||||
const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe;
|
||||
|
||||
const imageUrl = image.startsWith("Recipes/images/")
|
||||
? `/api/images?image=${image}&width=800`
|
||||
: image;
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-[400px] rounded-3xl overflow-hidden bg-black">
|
||||
<img
|
||||
src={recipe?.meta?.image + "?width=800"}
|
||||
src={imageUrl}
|
||||
alt="Recipe Banner"
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
@ -18,11 +24,11 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 py-4 px-8 py-8"
|
||||
style={{ background: "linear-gradient(#0000, #fff9)" }}
|
||||
>
|
||||
<h2 class="text-4xl font-bold mt-4" style={{ color: "#1F1F1F" }}>
|
||||
<div class="absolute inset-x-0 bottom-0 py-4 px-8 py-8 noisy-gradient">
|
||||
<h2
|
||||
class="relative text-4xl font-bold mt-4 z-10"
|
||||
style={{ color: "#1F1F1F" }}
|
||||
>
|
||||
{recipe.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@
|
||||
"lock": false,
|
||||
"tasks": {
|
||||
"start": "deno run -A --watch=static/,routes/ dev.ts",
|
||||
"debug": "deno run --inspect-wait -A main.ts",
|
||||
"update": "deno run -A -r https://fresh.deno.dev/update ."
|
||||
},
|
||||
"lint": {
|
||||
|
12
fresh.gen.ts
12
fresh.gen.ts
@ -4,9 +4,9 @@
|
||||
|
||||
import * as $0 from "./routes/_404.tsx";
|
||||
import * as $1 from "./routes/_app.tsx";
|
||||
import * as $2 from "./routes/api/index.ts";
|
||||
import * as $3 from "./routes/api/recipes/[name].ts";
|
||||
import * as $4 from "./routes/api/recipes/images/[image].ts";
|
||||
import * as $2 from "./routes/api/images/index.ts";
|
||||
import * as $3 from "./routes/api/index.ts";
|
||||
import * as $4 from "./routes/api/recipes/[name].ts";
|
||||
import * as $5 from "./routes/api/recipes/index.ts";
|
||||
import * as $6 from "./routes/index.tsx";
|
||||
import * as $7 from "./routes/recipes/[name].tsx";
|
||||
@ -17,9 +17,9 @@ const manifest = {
|
||||
routes: {
|
||||
"./routes/_404.tsx": $0,
|
||||
"./routes/_app.tsx": $1,
|
||||
"./routes/api/index.ts": $2,
|
||||
"./routes/api/recipes/[name].ts": $3,
|
||||
"./routes/api/recipes/images/[image].ts": $4,
|
||||
"./routes/api/images/index.ts": $2,
|
||||
"./routes/api/index.ts": $3,
|
||||
"./routes/api/recipes/[name].ts": $4,
|
||||
"./routes/api/recipes/index.ts": $5,
|
||||
"./routes/index.tsx": $6,
|
||||
"./routes/recipes/[name].tsx": $7,
|
||||
|
29
lib/cache.ts
29
lib/cache.ts
@ -1,29 +0,0 @@
|
||||
import { connect } from "https://deno.land/x/redis/mod.ts";
|
||||
|
||||
const REDIS_HOST = Deno.env.get("REDIS_HOST");
|
||||
const REDIS_PASS = Deno.env.get("REDIS_PASS");
|
||||
const REDIS_PORT = Deno.env.get("REDIS_PORT");
|
||||
|
||||
async function createCache() {
|
||||
if (REDIS_HOST && REDIS_PASS) {
|
||||
const client = await connect({
|
||||
password: REDIS_PASS,
|
||||
hostname: REDIS_HOST,
|
||||
port: REDIS_PORT || 6379,
|
||||
});
|
||||
console.log("COnnected to redis");
|
||||
return client;
|
||||
}
|
||||
|
||||
return new Map<string, any>();
|
||||
}
|
||||
|
||||
const cache = await createCache();
|
||||
|
||||
export async function get(id: string) {
|
||||
return await cache.get(id);
|
||||
}
|
||||
|
||||
export async function set(id: string, content: any) {
|
||||
return await cache.set(id, content);
|
||||
}
|
42
lib/cache/cache.ts
vendored
Normal file
42
lib/cache/cache.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
import {
|
||||
connect,
|
||||
Redis,
|
||||
RedisConnectOptions,
|
||||
RedisValue,
|
||||
} from "https://deno.land/x/redis@v0.31.0/mod.ts";
|
||||
|
||||
const REDIS_HOST = Deno.env.get("REDIS_HOST");
|
||||
const REDIS_PASS = Deno.env.get("REDIS_PASS") || "";
|
||||
const REDIS_PORT = Deno.env.get("REDIS_PORT");
|
||||
|
||||
async function createCache<T>(): Promise<Map<string, T> | Redis> {
|
||||
if (REDIS_HOST) {
|
||||
const conf: RedisConnectOptions = {
|
||||
hostname: REDIS_HOST,
|
||||
port: REDIS_PORT || 6379,
|
||||
};
|
||||
if (REDIS_PASS) {
|
||||
conf.password = REDIS_PASS;
|
||||
}
|
||||
const client = await connect(conf);
|
||||
console.log("Connected to redis");
|
||||
return client;
|
||||
}
|
||||
|
||||
return new Map<string, T>();
|
||||
}
|
||||
|
||||
const cache = await createCache();
|
||||
|
||||
export async function get<T>(id: string, binary = false) {
|
||||
if (binary && !(cache instanceof Map)) {
|
||||
return await cache.sendCommand("GET", [id], {
|
||||
returnUint8Arrays: true,
|
||||
}) as T;
|
||||
}
|
||||
return await cache.get(id) as T;
|
||||
}
|
||||
|
||||
export async function set<T extends RedisValue>(id: string, content: T) {
|
||||
return await cache.set(id, content);
|
||||
}
|
57
lib/cache/documents.ts
vendored
Normal file
57
lib/cache/documents.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
import { Document } from "@lib/documents.ts";
|
||||
import * as cache from "@lib/cache/cache.ts";
|
||||
|
||||
type DocumentsCache = {
|
||||
lastUpdated: number;
|
||||
documents: Document[];
|
||||
};
|
||||
|
||||
const CACHE_INTERVAL = 5000; // 5 seconds;
|
||||
const CACHE_KEY = "documents";
|
||||
|
||||
export async function getDocuments() {
|
||||
const docs = await cache.get<DocumentsCache>(CACHE_KEY);
|
||||
if (!docs) return;
|
||||
|
||||
if (Date.now() > docs.lastUpdated + CACHE_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
return docs.documents;
|
||||
}
|
||||
|
||||
export function setDocuments(documents: Document[]) {
|
||||
return cache.set(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({
|
||||
lastUpdated: Date.now(),
|
||||
documents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type DocumentCache = {
|
||||
lastUpdated: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export async function getDocument(id: string) {
|
||||
const doc = await cache.get<DocumentCache>(CACHE_KEY + "/" + id);
|
||||
if (!doc) return;
|
||||
|
||||
if (Date.now() > doc.lastUpdated + CACHE_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
return doc.content;
|
||||
}
|
||||
|
||||
export async function setDocument(id: string, content: string) {
|
||||
await cache.set(
|
||||
CACHE_KEY + "/" + id,
|
||||
JSON.stringify({
|
||||
lastUpdated: Date.now(),
|
||||
content,
|
||||
}),
|
||||
);
|
||||
}
|
63
lib/cache/image.ts
vendored
Normal file
63
lib/cache/image.ts
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
import { hash } from "@lib/hash.ts";
|
||||
import * as cache from "@lib/cache/cache.ts";
|
||||
|
||||
type ImageCacheOptions = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
const CACHE_KEY = "images";
|
||||
|
||||
function getCacheKey({ url: _url, width, height }: ImageCacheOptions) {
|
||||
const url = new URL(_url);
|
||||
|
||||
return `${CACHE_KEY}/${url.hostname}/${url.pathname}/${width}/${height}`
|
||||
.replace(
|
||||
"//",
|
||||
"/",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getImage({ url, width, height }: ImageCacheOptions) {
|
||||
const cacheKey = getCacheKey({ url, width, height });
|
||||
|
||||
const pointerCacheRaw = await cache.get<string>(cacheKey);
|
||||
if (!pointerCacheRaw) return;
|
||||
|
||||
const pointerCache = typeof pointerCacheRaw === "string"
|
||||
? JSON.parse(pointerCacheRaw)
|
||||
: pointerCacheRaw;
|
||||
|
||||
const imageContent = await cache.get(pointerCache.id, true);
|
||||
if (!imageContent) return;
|
||||
|
||||
return {
|
||||
...pointerCache,
|
||||
buffer: imageContent,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setImage(
|
||||
buffer: Uint8Array,
|
||||
{ url, width, height, mediaType }: ImageCacheOptions,
|
||||
) {
|
||||
const clone = new Uint8Array(buffer);
|
||||
|
||||
const cacheKey = getCacheKey({ url, width, height });
|
||||
const pointerId = await hash(cacheKey);
|
||||
|
||||
await cache.set(pointerId, clone);
|
||||
|
||||
await cache.set(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
id: pointerId,
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
mediaType,
|
||||
}),
|
||||
);
|
||||
}
|
@ -2,6 +2,7 @@ import { unified } from "npm:unified";
|
||||
import remarkParse from "npm:remark-parse";
|
||||
import remarkFrontmatter from "https://esm.sh/remark-frontmatter@4";
|
||||
import { parse } from "https://deno.land/std@0.194.0/yaml/mod.ts";
|
||||
import * as cache from "@lib/cache/documents.ts";
|
||||
|
||||
const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
|
||||
|
||||
@ -18,6 +19,9 @@ export function parseFrontmatter(yaml: string) {
|
||||
}
|
||||
|
||||
export async function getDocuments(): Promise<Document[]> {
|
||||
const cachedDocuments = await cache.getDocuments();
|
||||
if (cachedDocuments) return cachedDocuments;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Accept", "application/json");
|
||||
|
||||
@ -25,12 +29,22 @@ export async function getDocuments(): Promise<Document[]> {
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
return response.json();
|
||||
const documents = await response.json();
|
||||
cache.setDocuments(documents);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
export async function getDocument(name: string): Promise<string> {
|
||||
const cachedDocument = await cache.getDocument(name);
|
||||
if (cachedDocument) return cachedDocument;
|
||||
|
||||
const response = await fetch(SILVERBULLET_SERVER + "/" + name);
|
||||
return await response.text();
|
||||
const text = await response.text();
|
||||
|
||||
cache.setDocument(name, text);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function parseDocument(doc: string) {
|
||||
|
9
lib/hash.ts
Normal file
9
lib/hash.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export async function hash(message: string) {
|
||||
const data = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
return hashHex;
|
||||
}
|
@ -4,7 +4,7 @@ import {
|
||||
getTextOfRange,
|
||||
parseDocument,
|
||||
parseFrontmatter,
|
||||
} from "./documents.ts";
|
||||
} from "@lib/documents.ts";
|
||||
|
||||
import { parseIngredient } from "npm:parse-ingredient";
|
||||
|
||||
@ -161,10 +161,7 @@ export function parseRecipe(original: string, id: string): Recipe {
|
||||
|
||||
return {
|
||||
id,
|
||||
meta: {
|
||||
...meta,
|
||||
image: meta?.image?.replace(/^Recipes\/images/, "/api/recipes/images"),
|
||||
},
|
||||
meta,
|
||||
name,
|
||||
description,
|
||||
ingredients,
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
MagickGeometry,
|
||||
} from "https://deno.land/x/imagemagick_deno@0.0.14/mod.ts";
|
||||
import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts";
|
||||
import * as cache from "@lib/cache.ts";
|
||||
import * as cache from "@lib/cache/image.ts";
|
||||
|
||||
await initializeImageMagick();
|
||||
|
||||
@ -56,6 +56,11 @@ function modifyImage(
|
||||
}
|
||||
|
||||
function parseParams(reqUrl: URL) {
|
||||
const image = reqUrl.searchParams.get("image")?.replace(/^\//, "");
|
||||
if (image == null) {
|
||||
return "Missing 'image' query parameter.";
|
||||
}
|
||||
|
||||
const height = Number(reqUrl.searchParams.get("height")) || 0;
|
||||
const width = Number(reqUrl.searchParams.get("width")) || 0;
|
||||
if (height === 0 && width === 0) {
|
||||
@ -69,50 +74,36 @@ function parseParams(reqUrl: URL) {
|
||||
return `Width and height cannot exceed ${maxDimension}.`;
|
||||
}
|
||||
return {
|
||||
image,
|
||||
height,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
async function getImageResponse(
|
||||
remoteImage: { buffer: Uint8Array; mediaType: string },
|
||||
params: { width: number; height: number },
|
||||
): Promise<Response> {
|
||||
const modifiedImage = await modifyImage(remoteImage.buffer, {
|
||||
...params,
|
||||
mode: "resize",
|
||||
});
|
||||
|
||||
const response = new Response(modifiedImage, {
|
||||
headers: {
|
||||
"Content-Type": remoteImage.mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const handler = async (
|
||||
_req: Request,
|
||||
_ctx: HandlerContext,
|
||||
): Promise<Response> => {
|
||||
const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/Recipes/images/" +
|
||||
_ctx.params.image;
|
||||
|
||||
const url = new URL(_req.url);
|
||||
|
||||
const params = parseParams(url);
|
||||
|
||||
if (typeof params === "string") {
|
||||
return new Response(params, { status: 400 });
|
||||
}
|
||||
|
||||
const imageId = `${imageUrl}.${params.width}.${params.height}`;
|
||||
const cachedResponse = await cache.get(imageId);
|
||||
const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/" + params.image;
|
||||
|
||||
const cachedResponse = await cache.getImage({
|
||||
url: imageUrl,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
});
|
||||
if (cachedResponse) {
|
||||
const _r = await cachedResponse;
|
||||
console.log({ cachedResponse, _r });
|
||||
return (await cachedResponse).clone();
|
||||
return new Response(cachedResponse.buffer.slice(), {
|
||||
headers: {
|
||||
"Content-Type": cachedResponse.mediaType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const remoteImage = await getRemoteImage(imageUrl);
|
||||
@ -120,9 +111,21 @@ export const handler = async (
|
||||
return new Response(remoteImage, { status: 400 });
|
||||
}
|
||||
|
||||
const response = getImageResponse(remoteImage, params);
|
||||
const modifiedImage = await modifyImage(remoteImage.buffer, {
|
||||
...params,
|
||||
mode: "resize",
|
||||
});
|
||||
|
||||
await cache.set(imageId, response);
|
||||
cache.setImage(modifiedImage, {
|
||||
url: imageUrl,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
mediaType: remoteImage.mediaType,
|
||||
});
|
||||
|
||||
return response;
|
||||
return new Response(modifiedImage, {
|
||||
headers: {
|
||||
"Content-Type": remoteImage.mediaType,
|
||||
},
|
||||
});
|
||||
};
|
@ -1,6 +1,8 @@
|
||||
import { Head } from "$fresh/runtime.ts";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import Counter from "@islands/Counter.tsx";
|
||||
import { MainLayout } from "@components/layouts/main.tsx";
|
||||
import { Card } from "@components/Card.tsx";
|
||||
|
||||
export default function Home() {
|
||||
const count = useSignal(3);
|
||||
@ -9,23 +11,15 @@ export default function Home() {
|
||||
<Head>
|
||||
<title>app</title>
|
||||
</Head>
|
||||
<div class="px-4 py-8 mx-auto bg-[#86efac]">
|
||||
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
|
||||
<img
|
||||
class="my-6"
|
||||
src="/logo.svg"
|
||||
width="128"
|
||||
height="128"
|
||||
alt="the fresh logo: a sliced lemon dripping with juice"
|
||||
<MainLayout>
|
||||
<div class="flex flex-wrap justify-center items-center gap-4 px-4">
|
||||
<Card
|
||||
title="Recipes"
|
||||
image="/api/images?image=Recipes/images/placeholder.jpg&width=200&height=200"
|
||||
link="/recipes"
|
||||
/>
|
||||
<h1 class="text-4xl font-bold">Welcome to fresh</h1>
|
||||
<p class="my-4">
|
||||
Try updating this message in the
|
||||
<code class="mx-2">./routes/index.tsx</code> file, and refresh.
|
||||
</p>
|
||||
<Counter count={count} />
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { RecipeCard } from "@components/RecipeCard.tsx";
|
||||
import { MainLayout } from "@components/layouts/main.tsx";
|
||||
import { Recipe } from "@lib/recipes.ts";
|
||||
import { getRecipes } from "../api/recipes/index.ts";
|
||||
|
||||
import IconArrowLeft from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-left.tsx";
|
||||
export const handler: Handlers<Recipe[] | null> = {
|
||||
async GET(_, ctx) {
|
||||
const recipes = await getRecipes();
|
||||
@ -14,6 +14,17 @@ export const handler: Handlers<Recipe[] | null> = {
|
||||
export default function Greet(props: PageProps<Recipe[] | null>) {
|
||||
return (
|
||||
<MainLayout>
|
||||
<header class="flex gap-4 items-center mb-5">
|
||||
<a
|
||||
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
||||
href="/"
|
||||
>
|
||||
<IconArrowLeft class="w-5 h-5" />
|
||||
Back
|
||||
</a>
|
||||
|
||||
<h3 class="text-2xl text-white font-light">Recipes</h3>
|
||||
</header>
|
||||
<div class="flex flex-wrap justify-center items-center gap-4 px-4">
|
||||
{props.data?.map((doc) => {
|
||||
return <RecipeCard recipe={doc} />;
|
||||
|
@ -1,3 +1,17 @@
|
||||
body {
|
||||
background: #1F1F1F
|
||||
background: #1F1F1F;
|
||||
padding: 0px 20px;
|
||||
}
|
||||
|
||||
.noisy-gradient::after {
|
||||
content: "";
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index:0;
|
||||
position: absolute;
|
||||
opacity: 0.7;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: url(/grainy-gradient.png);
|
||||
background-size: contain;
|
||||
}
|
||||
|
BIN
static/grainy-gradient.png
Normal file
BIN
static/grainy-gradient.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
Loading…
x
Reference in New Issue
Block a user