feat: add dropzone, update tool page
This commit is contained in:
@@ -331,7 +331,7 @@ src/routes/preview/tools/[id]/+page.svelte
|
|||||||
подключим на C12).
|
подключим на C12).
|
||||||
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью), статичный приёмочный стенд.
|
||||||
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel + preview-panel (gradient live, остальные — плейсхолдер); `NumberField`, `entries()` для prerender, ToolCard-ссылки подключены.
|
||||||
- [ ] **[C13]** `/preview/tools/[id]` — раскладка background-remover (comparison-grid).
|
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`); охватывает background-remover и прочие.
|
||||||
- [ ] **[C14]** step-cards для chain в tool-page.
|
- [ ] **[C14]** step-cards для chain в tool-page.
|
||||||
|
|
||||||
### Шаг 4. Переключатель и проверка
|
### Шаг 4. Переключатель и проверка
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Upload } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accept?: string;
|
||||||
|
onfile?: (file: File) => void;
|
||||||
|
}
|
||||||
|
let { accept = "image/*", onfile }: Props = $props();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function handleFiles(files: FileList | null | undefined) {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (file) onfile?.(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
handleFiles(e.dataTransfer?.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
input?.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="dropzone"
|
||||||
|
class:dragging
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={onDrop}
|
||||||
|
onclick={open}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
type="file"
|
||||||
|
{accept}
|
||||||
|
hidden
|
||||||
|
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||||
|
/>
|
||||||
|
<Upload size={22} />
|
||||||
|
<span class="dz-title">Drop a PNG here</span>
|
||||||
|
<span class="dz-sub">or click to browse — processed locally</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--background);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
.dropzone:hover,
|
||||||
|
.dropzone:focus-visible,
|
||||||
|
.dropzone.dragging {
|
||||||
|
border-color: var(--blue);
|
||||||
|
color: var(--foreground);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dz-title {
|
||||||
|
font: 600 0.9rem var(--font-mono);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.dz-sub {
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { getTool, defaultParams } from "$lib/registry";
|
import { getTool, defaultParams } from "$lib/registry";
|
||||||
import { encode, downloadBlob, toDataUrl } from "$lib/core/io";
|
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
|
||||||
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import Panel from "$lib/components/kit/Panel.svelte";
|
import Panel from "$lib/components/kit/Panel.svelte";
|
||||||
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
|
import PanelHeading from "$lib/components/kit/PanelHeading.svelte";
|
||||||
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
|
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
|
||||||
@@ -12,13 +13,14 @@
|
|||||||
import Toggle from "$lib/components/kit/Toggle.svelte";
|
import Toggle from "$lib/components/kit/Toggle.svelte";
|
||||||
import TextField from "$lib/components/kit/TextField.svelte";
|
import TextField from "$lib/components/kit/TextField.svelte";
|
||||||
import NumberField from "$lib/components/kit/NumberField.svelte";
|
import NumberField from "$lib/components/kit/NumberField.svelte";
|
||||||
import PreviewTile from "$lib/components/kit/PreviewTile.svelte";
|
|
||||||
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
|
import CheckerCanvas from "$lib/components/kit/CheckerCanvas.svelte";
|
||||||
import MetaList from "$lib/components/kit/MetaList.svelte";
|
import MetaList from "$lib/components/kit/MetaList.svelte";
|
||||||
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
|
import DownloadButton from "$lib/components/kit/DownloadButton.svelte";
|
||||||
import Badge from "$lib/components/kit/Badge.svelte";
|
import Badge from "$lib/components/kit/Badge.svelte";
|
||||||
import StatusLine from "$lib/components/kit/StatusLine.svelte";
|
import StatusLine from "$lib/components/kit/StatusLine.svelte";
|
||||||
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
||||||
|
import Button from "$lib/components/kit/Button.svelte";
|
||||||
|
import Dropzone from "$lib/components/kit/Dropzone.svelte";
|
||||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||||
|
|
||||||
type ParamValue = string | number | boolean;
|
type ParamValue = string | number | boolean;
|
||||||
@@ -37,6 +39,13 @@
|
|||||||
let previewUrl = $state("");
|
let previewUrl = $state("");
|
||||||
let previewError = $state("");
|
let previewError = $state("");
|
||||||
|
|
||||||
|
let sourceImg = $state<PixelImage | null>(null);
|
||||||
|
let sourceUrl = $state("");
|
||||||
|
let sourceName = $state("");
|
||||||
|
let resultImg = $state<PixelImage | null>(null);
|
||||||
|
let resultUrl = $state("");
|
||||||
|
let showMask = $state(false);
|
||||||
|
|
||||||
const tool = $derived(getTool(data.id));
|
const tool = $derived(getTool(data.id));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -71,6 +80,57 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const src = sourceImg;
|
||||||
|
if (!tool || !tool.run || !src) {
|
||||||
|
resultImg = null;
|
||||||
|
resultUrl = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!browser) return;
|
||||||
|
const snapshot = { ...params };
|
||||||
|
const mask = showMask && !!tool.preview;
|
||||||
|
try {
|
||||||
|
const base = mask
|
||||||
|
? tool.preview!(src, snapshot)
|
||||||
|
: tool.run(src, snapshot);
|
||||||
|
const apply = (img: PixelImage) => {
|
||||||
|
resultImg = img;
|
||||||
|
resultUrl = toDataUrl(img);
|
||||||
|
previewError = "";
|
||||||
|
};
|
||||||
|
if (base instanceof Promise) {
|
||||||
|
base.then(apply).catch((e) => (previewError = String(e)));
|
||||||
|
} else {
|
||||||
|
apply(base);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
previewError = String(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onFile(file: File) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const img = await decodeBytes(bytes);
|
||||||
|
sourceImg = img;
|
||||||
|
sourceName = file.name;
|
||||||
|
sourceUrl = toDataUrl(img);
|
||||||
|
previewError = "";
|
||||||
|
} catch (e) {
|
||||||
|
previewError = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSource() {
|
||||||
|
sourceImg = null;
|
||||||
|
sourceUrl = "";
|
||||||
|
sourceName = "";
|
||||||
|
resultImg = null;
|
||||||
|
resultUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
if (!tool?.generate) return;
|
if (!tool?.generate) return;
|
||||||
const result = tool.generate({ ...params });
|
const result = tool.generate({ ...params });
|
||||||
@@ -79,10 +139,21 @@
|
|||||||
downloadBlob(blob, `${tool.id}.png`);
|
downloadBlob(blob, `${tool.id}.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadResult() {
|
||||||
|
if (!resultImg || !tool) return;
|
||||||
|
const blob = await encode(resultImg, "image/png");
|
||||||
|
const base = sourceName.replace(/\.[^.]+$/, "");
|
||||||
|
downloadBlob(blob, `${base}-${tool.id}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
function setParam(id: string, value: ParamValue) {
|
function setParam(id: string, value: ParamValue) {
|
||||||
params[id] = value;
|
params[id] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFileTool = $derived(
|
||||||
|
tool ? tool.sourceMode === "file" || tool.sourceMode === undefined : false,
|
||||||
|
);
|
||||||
|
|
||||||
const meta = $derived(
|
const meta = $derived(
|
||||||
tool
|
tool
|
||||||
? [
|
? [
|
||||||
@@ -108,26 +179,16 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !tool}
|
{#snippet settingsPanel()}
|
||||||
<div class="tool-grid">
|
|
||||||
<EmptyState
|
|
||||||
title="Tool not found"
|
|
||||||
description="No tool is registered under “{data.id}”."
|
|
||||||
icon={ToolIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="tool-grid">
|
|
||||||
<section class="settings-panel">
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeading title={tool.title} eyebrow={tool.category}>
|
<PanelHeading title={tool!.title} eyebrow={tool!.category}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Badge tone="accent">AUTO</Badge>
|
<Badge tone="accent">AUTO</Badge>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PanelHeading>
|
</PanelHeading>
|
||||||
<p class="tool-desc">{tool.description}</p>
|
<p class="tool-desc">{tool!.description}</p>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
{#each tool.params as p (p.id)}
|
{#each tool!.params as p (p.id)}
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
{#if p.type === "color"}
|
{#if p.type === "color"}
|
||||||
<ColorField
|
<ColorField
|
||||||
@@ -183,8 +244,19 @@
|
|||||||
<StatusLine label="changes applied automatically" />
|
<StatusLine label="changes applied automatically" />
|
||||||
</SettingsFooter>
|
</SettingsFooter>
|
||||||
</Panel>
|
</Panel>
|
||||||
</section>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !tool}
|
||||||
|
<div class="tool-grid">
|
||||||
|
<EmptyState
|
||||||
|
title="Tool not found"
|
||||||
|
description="No tool is registered under “{data.id}”."
|
||||||
|
icon={ToolIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if tool.sourceMode === "none"}
|
||||||
|
<div class="tool-grid">
|
||||||
|
<section class="settings-panel">{@render settingsPanel()}</section>
|
||||||
<section class="preview-panel">
|
<section class="preview-panel">
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeading title="Preview" eyebrow="OUTPUT">
|
<PanelHeading title="Preview" eyebrow="OUTPUT">
|
||||||
@@ -196,11 +268,9 @@
|
|||||||
{#if previewError}
|
{#if previewError}
|
||||||
<EmptyState title="Preview failed" description={previewError} />
|
<EmptyState title="Preview failed" description={previewError} />
|
||||||
{:else if previewUrl}
|
{:else if previewUrl}
|
||||||
<PreviewTile label="RESULT">
|
|
||||||
<CheckerCanvas size="large">
|
<CheckerCanvas size="large">
|
||||||
<img class="result-img" src={previewUrl} alt="result" />
|
<img class="result-img" src={previewUrl} alt="result" />
|
||||||
</CheckerCanvas>
|
</CheckerCanvas>
|
||||||
</PreviewTile>
|
|
||||||
{:else if tool.generate}
|
{:else if tool.generate}
|
||||||
<div class="preview-loading">
|
<div class="preview-loading">
|
||||||
<StatusLine label="generating preview" />
|
<StatusLine label="generating preview" />
|
||||||
@@ -208,7 +278,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No live preview"
|
title="No live preview"
|
||||||
description="This tool runs on an uploaded image. Source upload arrives in a later step."
|
description="This tool runs on an uploaded image."
|
||||||
icon={ToolIcon}
|
icon={ToolIcon}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -217,6 +287,89 @@
|
|||||||
</Panel>
|
</Panel>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if isFileTool}
|
||||||
|
<div class="tool-grid remover-grid">
|
||||||
|
<section class="settings-panel">{@render settingsPanel()}</section>
|
||||||
|
<section class="preview-panel">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeading title="Source / Result" eyebrow="OUTPUT">
|
||||||
|
{#snippet actions()}
|
||||||
|
{#if resultUrl}
|
||||||
|
<DownloadButton
|
||||||
|
label="Download result"
|
||||||
|
onclick={downloadResult}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PanelHeading>
|
||||||
|
<div class="preview-body">
|
||||||
|
{#if previewError}
|
||||||
|
<EmptyState title="Processing failed" description={previewError} />
|
||||||
|
{/if}
|
||||||
|
{#if !sourceImg}
|
||||||
|
<Dropzone onfile={onFile} />
|
||||||
|
{:else}
|
||||||
|
<div class="comparison-grid">
|
||||||
|
<div class="image-card">
|
||||||
|
<div class="image-label">
|
||||||
|
<span>SOURCE</span><b>{sourceName}</b>
|
||||||
|
</div>
|
||||||
|
<div class="remover-canvas">
|
||||||
|
<CheckerCanvas size="large">
|
||||||
|
<img class="cmp-img" src={sourceUrl} alt="source" />
|
||||||
|
</CheckerCanvas>
|
||||||
|
<span class="canvas-size"
|
||||||
|
>{sourceImg.width} × {sourceImg.height}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-card">
|
||||||
|
<div class="image-label">
|
||||||
|
<span>RESULT</span><b>{sourceName}</b>
|
||||||
|
</div>
|
||||||
|
<div class="remover-canvas">
|
||||||
|
{#if resultUrl}
|
||||||
|
<CheckerCanvas size="large">
|
||||||
|
<img class="cmp-img" src={resultUrl} alt="result" />
|
||||||
|
</CheckerCanvas>
|
||||||
|
{/if}
|
||||||
|
<span class="canvas-size"
|
||||||
|
>{resultImg
|
||||||
|
? `${resultImg.width} × ${resultImg.height}`
|
||||||
|
: "—"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-meta">
|
||||||
|
<span>FORMAT <b>PNG-24</b></span>
|
||||||
|
<span>ALPHA <b>ENABLED</b></span>
|
||||||
|
<span>STATUS <b class="ok">PROCESSED</b></span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onclick={clearSource}
|
||||||
|
>Change image</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="tool-grid">
|
||||||
|
<section class="settings-panel">{@render settingsPanel()}</section>
|
||||||
|
<section class="preview-panel">
|
||||||
|
<Panel>
|
||||||
|
<PanelHeading title="Preview" eyebrow="OUTPUT" />
|
||||||
|
<div class="preview-body">
|
||||||
|
<EmptyState
|
||||||
|
title="Text source"
|
||||||
|
description="Paste text on the left — arrives in a later step."
|
||||||
|
icon={ToolIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -229,6 +382,9 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px clamp(24px, 4vw, 72px) 72px;
|
padding: 48px clamp(24px, 4vw, 72px) 72px;
|
||||||
}
|
}
|
||||||
|
.remover-grid {
|
||||||
|
grid-template-columns: minmax(360px, 0.62fr) minmax(0, 1.38fr);
|
||||||
|
}
|
||||||
.tool-desc {
|
.tool-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
@@ -277,8 +433,69 @@
|
|||||||
display: block;
|
display: block;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
.comparison-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.image-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.image-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.image-label span {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
.image-label b {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.remover-canvas {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cmp-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 420px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.canvas-size {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 9px;
|
||||||
|
right: 10px;
|
||||||
|
font: 9px var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font: 10px var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.result-meta b {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.result-meta b.ok {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.tool-grid {
|
.tool-grid,
|
||||||
|
.remover-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 32px 16px 48px;
|
padding: 32px 16px 48px;
|
||||||
@@ -286,5 +503,8 @@
|
|||||||
.preview-panel {
|
.preview-panel {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
.comparison-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user