mirror of
https://github.com/cupcakearmy/blaze.git
synced 2024-12-22 08:16:26 +00:00
proposal
This commit is contained in:
parent
f77ddf3c35
commit
5d7cbebceb
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
*
|
||||||
|
!src
|
||||||
|
!templates
|
||||||
|
!package.json
|
||||||
|
!pnpm-lock.yaml
|
||||||
|
!tsconfig.json
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env*
|
||||||
|
dist
|
||||||
|
node_modules
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.eta": "html"
|
||||||
|
}
|
||||||
|
}
|
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM node:20-slim AS base
|
||||||
|
ENV CI=true
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install
|
||||||
|
COPY . /app
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
FROM base
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build /app/dist /app/dist
|
||||||
|
COPY --from=build /app/templates /app/templates
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD [ "pnpm", "start" ]
|
8
docker-compose.yaml
Normal file
8
docker-compose.yaml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- 80:8000
|
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "blaze-this-page",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "The service to navigate the web also with bad connectivity",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"start": "node ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/compress": "^6.4.0",
|
||||||
|
"@fastify/env": "^4.2.0",
|
||||||
|
"@fastify/etag": "^4.2.0",
|
||||||
|
"@fastify/static": "^6.10.2",
|
||||||
|
"@fastify/view": "^8.0.0",
|
||||||
|
"eta": "^3.1.1",
|
||||||
|
"fastify": "^4.21.0",
|
||||||
|
"fastify-minify": "^1.2.0",
|
||||||
|
"node-html-parser": "^6.1.6",
|
||||||
|
"zod": "^3.22.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^8.0.2",
|
||||||
|
"@types/node": "^20.5.6",
|
||||||
|
"pino-pretty": "^10.2.0",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.6.12"
|
||||||
|
}
|
1113
pnpm-lock.yaml
generated
Normal file
1113
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
src/error.ts
Normal file
21
src/error.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export class BlazeError extends Error {
|
||||||
|
constructor(public code: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init(app: FastifyInstance) {
|
||||||
|
app.setErrorHandler((err, req, reply) => {
|
||||||
|
let code = 500
|
||||||
|
let message = 'Internal server error'
|
||||||
|
if (err instanceof BlazeError) {
|
||||||
|
code = err.code
|
||||||
|
message = err.message
|
||||||
|
} else {
|
||||||
|
req.log.error(err)
|
||||||
|
}
|
||||||
|
return reply.code(code).view('error', { title: 'Error', code, message })
|
||||||
|
})
|
||||||
|
}
|
30
src/html.ts
Normal file
30
src/html.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { parse } from 'node-html-parser'
|
||||||
|
|
||||||
|
export function replaceLinks(raw: string): string {
|
||||||
|
const html = parse(raw)
|
||||||
|
html.querySelectorAll('a').forEach((el) => {
|
||||||
|
if (el.hasAttribute('data-preserve-link')) return
|
||||||
|
const href = el.getAttribute('href')
|
||||||
|
if (href) {
|
||||||
|
el.setAttribute('href', `/browse?${new URLSearchParams({ url: href }).toString()}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return html.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanContent(raw: string, base: string): string {
|
||||||
|
const html = parse(raw)
|
||||||
|
|
||||||
|
const tagsToRemove = ['script', 'style', 'img', 'picture', 'video', 'iframe', 'svg']
|
||||||
|
for (const tag of tagsToRemove) {
|
||||||
|
html.querySelectorAll(tag).forEach((el) => el.remove())
|
||||||
|
}
|
||||||
|
html.querySelectorAll('a').forEach((el) => {
|
||||||
|
const href = el.getAttribute('href')
|
||||||
|
if (href) {
|
||||||
|
el.setAttribute('href', new URL(href, base).toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return html.querySelector('body')?.innerHTML ?? ''
|
||||||
|
}
|
74
src/index.ts
Normal file
74
src/index.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Eta } from 'eta'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { replaceLinks } from './html.js'
|
||||||
|
import { init as initRoutes } from './routes.js'
|
||||||
|
import { init as initError } from './error.js'
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger:
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
? true
|
||||||
|
: {
|
||||||
|
level: 'debug',
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
translateTime: 'HH:MM:ss Z',
|
||||||
|
ignore: 'pid,hostname,remoteAddress',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
config: {
|
||||||
|
BRAVE_KEY: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await app
|
||||||
|
.register(import('@fastify/compress'))
|
||||||
|
.register(import('@fastify/etag'))
|
||||||
|
.register(import('fastify-minify'), { global: true })
|
||||||
|
.register(import('@fastify/view'), {
|
||||||
|
engine: {
|
||||||
|
eta: new Eta({ tags: ['{{', '}}'] }),
|
||||||
|
},
|
||||||
|
layout: 'layout',
|
||||||
|
templates: path.resolve('templates'),
|
||||||
|
})
|
||||||
|
.register(import('@fastify/env'), {
|
||||||
|
dotenv: true,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['BRAVE_KEY'],
|
||||||
|
properties: {
|
||||||
|
BRAVE_KEY: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to blaze
|
||||||
|
app.addHook('onSend', async (req, reply, payload) => {
|
||||||
|
if (reply.getHeader('content-type')?.toString().startsWith('text/html')) {
|
||||||
|
return replaceLinks(payload as string)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
})
|
||||||
|
|
||||||
|
initError(app)
|
||||||
|
initRoutes(app)
|
||||||
|
|
||||||
|
// START
|
||||||
|
try {
|
||||||
|
await app.listen({ port: 8000, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
38
src/routes.ts
Normal file
38
src/routes.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { BlazeError } from './error.js'
|
||||||
|
import { cleanContent } from './html.js'
|
||||||
|
import { Brave, SearchEngine } from './search/index.js'
|
||||||
|
|
||||||
|
export function init(app: FastifyInstance) {
|
||||||
|
const engine: SearchEngine = new Brave(app.config.BRAVE_KEY)
|
||||||
|
|
||||||
|
const SearchSchema = z.strictObject({ q: z.string().min(1) })
|
||||||
|
app.get('/search', async (req, reply) => {
|
||||||
|
const query = SearchSchema.safeParse(req.query)
|
||||||
|
if (query.success) {
|
||||||
|
const { q } = query.data
|
||||||
|
const results = await engine.search(q)
|
||||||
|
return reply.view('results', { results, title: `Blaze - ${q}`, needle: q })
|
||||||
|
} else {
|
||||||
|
throw new BlazeError(401, query.error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const BrowseSchema = z.strictObject({ url: z.string().url() })
|
||||||
|
app.get('/browse', async (req, reply) => {
|
||||||
|
const query = BrowseSchema.safeParse(req.query)
|
||||||
|
if (query.success) {
|
||||||
|
const { url } = query.data
|
||||||
|
const raw = await fetch(url).then((r) => r.text())
|
||||||
|
return reply.view('browse', { title: 'Blaze', content: cleanContent(raw, url), needle: '' })
|
||||||
|
} else {
|
||||||
|
throw new BlazeError(401, query.error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', (req, reply) => {
|
||||||
|
return reply.view('home', { title: 'Blaze', needle: '' })
|
||||||
|
})
|
||||||
|
}
|
92
src/search/brave.ts
Normal file
92
src/search/brave.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { SearchEngine } from './index.js'
|
||||||
|
|
||||||
|
// https://transform.tools/json-to-typescript
|
||||||
|
export interface Root {
|
||||||
|
query: Query
|
||||||
|
mixed: Mixed
|
||||||
|
type: string
|
||||||
|
web: Web
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Query {
|
||||||
|
original: string
|
||||||
|
show_strict_warning: boolean
|
||||||
|
is_navigational: boolean
|
||||||
|
is_news_breaking: boolean
|
||||||
|
spellcheck_off: boolean
|
||||||
|
country: string
|
||||||
|
bad_results: boolean
|
||||||
|
should_fallback: boolean
|
||||||
|
postal_code: string
|
||||||
|
city: string
|
||||||
|
header_country: string
|
||||||
|
more_results_available: boolean
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mixed {
|
||||||
|
type: string
|
||||||
|
main: Main[]
|
||||||
|
top: any[]
|
||||||
|
side: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Main {
|
||||||
|
type: string
|
||||||
|
index: number
|
||||||
|
all: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Web {
|
||||||
|
type: string
|
||||||
|
results: Result[]
|
||||||
|
family_friendly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
is_source_local: boolean
|
||||||
|
is_source_both: boolean
|
||||||
|
description: string
|
||||||
|
language: string
|
||||||
|
family_friendly: boolean
|
||||||
|
type: string
|
||||||
|
subtype: string
|
||||||
|
meta_url: MetaUrl
|
||||||
|
age?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaUrl {
|
||||||
|
scheme: string
|
||||||
|
netloc: string
|
||||||
|
hostname: string
|
||||||
|
favicon: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Brave implements SearchEngine {
|
||||||
|
private static URL = 'https://api.search.brave.com/res/v1/web/search'
|
||||||
|
|
||||||
|
constructor(private key: string) {}
|
||||||
|
|
||||||
|
async search(query: string) {
|
||||||
|
const url = `${Brave.URL}?${new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
safesearch: 'moderate',
|
||||||
|
result_filter: 'web',
|
||||||
|
}).toString()}`
|
||||||
|
|
||||||
|
const data: Root = await fetch(url, {
|
||||||
|
headers: { 'X-Subscription-Token': this.key },
|
||||||
|
}).then((response) => response.json())
|
||||||
|
|
||||||
|
return data.web.results.map((result) => {
|
||||||
|
return {
|
||||||
|
title: result.title,
|
||||||
|
description: result.description,
|
||||||
|
url: result.url,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
11
src/search/index.ts
Normal file
11
src/search/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type Result = {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class SearchEngine {
|
||||||
|
abstract search(query: string): Promise<Result[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './brave.js'
|
12
src/search/mock.ts
Normal file
12
src/search/mock.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Result, SearchEngine } from './index.js'
|
||||||
|
import { faker } from '@faker-js/faker'
|
||||||
|
|
||||||
|
export class Mock implements SearchEngine {
|
||||||
|
async search(query: string): Promise<Result[]> {
|
||||||
|
return [...new Array(20)].map((_) => ({
|
||||||
|
title: faker.internet.displayName(),
|
||||||
|
url: faker.internet.url(),
|
||||||
|
description: faker.lorem.sentences(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
2
templates/browse.eta
Normal file
2
templates/browse.eta
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
{{~ include("./search.eta", it) }}
|
||||||
|
<section>{{~ it.content }}</section>
|
2
templates/error.eta
Normal file
2
templates/error.eta
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<h1>Error: {{= it.code }}</h1>
|
||||||
|
<p>{{= it.message }}</p>
|
3
templates/home.eta
Normal file
3
templates/home.eta
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div style="margin: 10rem auto">
|
||||||
|
{{~ include("./search", it) }}
|
||||||
|
</div>
|
136
templates/layout.eta
Normal file
136
templates/layout.eta
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{= it.title }}</title>
|
||||||
|
<style>
|
||||||
|
/*! modern-normalize v2.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||||
|
line-height: 1.15;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
height: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
abbr[title] {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
text-indent: 0;
|
||||||
|
border-color: inherit;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'],
|
||||||
|
button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
::-moz-focus-inner {
|
||||||
|
border-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
:-moz-focusring {
|
||||||
|
outline: 1px dotted ButtonText;
|
||||||
|
}
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--blaze-primary: #ff9400;
|
||||||
|
--blaze-bg: #fff8ee;
|
||||||
|
--blaze-text: #120c02;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
color: var(--blaze-text);
|
||||||
|
max-width: 50rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>{{~ it.body }}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
42
templates/results.eta
Normal file
42
templates/results.eta
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{{~ include("./search.eta", it) }}
|
||||||
|
<ol class="blaze_results">
|
||||||
|
{{ it.results.forEach(function(result){ }}
|
||||||
|
<li>
|
||||||
|
<a href="{{= result.url }}">
|
||||||
|
<article>
|
||||||
|
<pre>{{= result.url }}</pre>
|
||||||
|
<h2>{{= result.title }}</h2>
|
||||||
|
<p>{{~ result.description }}</p>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ }) }}
|
||||||
|
</ol>
|
||||||
|
<style>
|
||||||
|
.blaze_results li {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blaze_results a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blaze_results pre {
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
.blaze_results p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.blaze_results h2 {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.blaze_results {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
48
templates/search.eta
Normal file
48
templates/search.eta
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<form class="blaze_form" action="/search" method="get">
|
||||||
|
<a style="font-size: 2rem; font-weight: bold" href="/" data-preserve-link>Bla⚡️e</a>
|
||||||
|
<input type="search" name="q" placeholder="search something..." value="{{= it.needle }}" />
|
||||||
|
<button type="submit">Search 🔎</button>
|
||||||
|
</form>
|
||||||
|
<style>
|
||||||
|
form.blaze_form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px dashed black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blaze_form a {
|
||||||
|
text-decoration: inherit;
|
||||||
|
color: inherit;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blaze_form input,
|
||||||
|
.blaze_form button {
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: var(--blaze-bg);
|
||||||
|
color: var(--blaze-text);
|
||||||
|
border: 2px solid var(--blaze-primary);
|
||||||
|
}
|
||||||
|
.blaze_form input {
|
||||||
|
padding: 0.25rem 0.25rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.blaze_form button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 30rem) {
|
||||||
|
form.blaze_form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.blaze_form input,
|
||||||
|
.blaze_form button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"removeComments": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user