mirror of
https://github.com/alexandrev/xslt-lab.git
synced 2026-09-13 08:43:16 +00:00
version 0.2.0
This commit is contained in:
@@ -196,5 +196,6 @@ cython_debug/
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/server
|
||||
backend/src/.gocache
|
||||
/config/credentials.json
|
||||
credentials.json
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## v0.2.0
|
||||
|
||||
- Expand the transformation up to three independent workspaces, persist each workspace state, and add import/export controls for sharing setups.
|
||||
- Surface the GitHub Pages news/blog link directly in the app header and show the running UI version with a deep link to this changelog.
|
||||
- Introduce workspace JSON export/import plus per-workspace trace, error and result retention.
|
||||
- New look & feel to provide a better experience
|
||||
|
||||
|
||||
## v0.1.0
|
||||
|
||||
- Initial release.
|
||||
@@ -4,16 +4,24 @@ BACKEND_IMAGE=xslt-playground-backend
|
||||
FRONTEND_IMAGE=xslt-playground-frontend
|
||||
|
||||
|
||||
.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down
|
||||
.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down clean backend-test frontend-test test
|
||||
|
||||
all: backend-build frontend-build backend-image frontend-image compose-up
|
||||
all: backend-test frontend-test backend-build frontend-build backend-image frontend-image compose-up
|
||||
|
||||
backend-build:
|
||||
cd $(BACKEND_DIR)/src && go mod tidy && go build -o ../server
|
||||
|
||||
backend-test:
|
||||
cd $(BACKEND_DIR)/src && GOCACHE=$$(pwd)/.gocache go test ./...
|
||||
|
||||
frontend-build:
|
||||
cd $(FRONTEND_DIR) && npm install && npm run build
|
||||
|
||||
frontend-test:
|
||||
cd $(FRONTEND_DIR) && npm install && npm run test
|
||||
|
||||
test: backend-test frontend-test
|
||||
|
||||
backend-image:
|
||||
docker build --platform linux/amd64 -t $(BACKEND_IMAGE) $(BACKEND_DIR)
|
||||
docker tag $(BACKEND_IMAGE):latest ghcr.io/alexandrev/$(BACKEND_IMAGE):latest
|
||||
@@ -27,3 +35,10 @@ compose-up:
|
||||
|
||||
compose-down:
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
clean:
|
||||
-docker compose -f docker-compose.local.yml down --remove-orphans --rmi local
|
||||
@if docker image inspect $(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(BACKEND_IMAGE):latest; fi
|
||||
@if docker image inspect $(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(FRONTEND_IMAGE):latest; fi
|
||||
@if docker image inspect ghcr.io/alexandrev/$(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(BACKEND_IMAGE):latest; fi
|
||||
@if docker image inspect ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest; fi
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Your Lab for XSLT Transformation.
|
||||
|
||||
## News & Releases
|
||||
|
||||
- Follow updates on the GitHub Pages blog: [alexandrev.github.io/xslt-lab](https://alexandrev.github.io/xslt-lab/).
|
||||
- Review detailed changes in [CHANGELOG.md](https://github.com/alexandrev/xslt-lab/blob/main/CHANGELOG.md).
|
||||
|
||||
## Frontend
|
||||
|
||||
The React/Vite frontend lives in `frontend/`. Use `npm install` inside that folder and run:
|
||||
@@ -25,11 +30,15 @@ containerized version the URL is now read at **runtime** from environment
|
||||
variables so you can configure it directly in the pod.
|
||||
|
||||
When `VITE_GO_PRO=true` the UI exposes additional features like Google
|
||||
authentication and multiple transformation tabs. For authentication you must
|
||||
authentication. For authentication you must
|
||||
provide Firebase configuration via `VITE_FIREBASE_CONFIG` containing the JSON
|
||||
object used by `initializeApp`.
|
||||
Set `VITE_GA_ID` to enable Google Analytics tracking.
|
||||
|
||||
The playground keeps up to three independent workspaces (tabs). Each workspace
|
||||
persists its own inputs, trace output, errors and results, and you can export or
|
||||
import them as JSON files to share setups easily.
|
||||
|
||||
### Docker
|
||||
|
||||
To build a container with the compiled frontend run:
|
||||
@@ -70,7 +79,7 @@ its dependencies from `/opt/saxon/*`.
|
||||
The backend image also builds a small jar with custom Saxon extension
|
||||
functions. It gets copied to `/opt/saxon/custom-functions.jar` during the
|
||||
Docker build. You can call these from XSLT using the namespace
|
||||
`xmlns:tib="java:com.xsltplayground.ext.CustomFunctions"`. The jar exposes
|
||||
`xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions"`. The jar exposes
|
||||
many helper functions such as `tib:uuid()`, `tib:timestamp()` and
|
||||
`tib:addToDate()`.
|
||||
|
||||
@@ -148,4 +157,3 @@ helm install xslt charts/xslt-playground
|
||||
# helm install xslt charts/xslt-playground --set firebase.enabled=false
|
||||
# helm install xslt charts/xslt-playground --set storage.enabled=false
|
||||
```
|
||||
|
||||
|
||||
+3
-7
@@ -10,19 +10,15 @@ WORKDIR /app/src
|
||||
# Copiar los archivos
|
||||
COPY src/ .
|
||||
|
||||
RUN rm -rf go.mod go.sum
|
||||
|
||||
# Compilar el binario estático
|
||||
RUN go mod init xslt-playground && \
|
||||
go get github.com/gin-gonic/gin && \
|
||||
go mod tidy && \
|
||||
RUN go mod tidy && \
|
||||
go build -o server .
|
||||
|
||||
# Build extension functions jar
|
||||
WORKDIR /app/ext
|
||||
COPY ext/ .
|
||||
RUN mkdir -p /tmp/saxon && \
|
||||
curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \
|
||||
curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
|
||||
javac -cp /tmp/saxon/saxon-he.jar com/xsltplayground/ext/CustomFunctions.java com/xsltplayground/Runner.java && \
|
||||
jar cf custom-functions.jar com
|
||||
|
||||
@@ -43,7 +39,7 @@ COPY app.config .
|
||||
|
||||
# Crear carpeta para Saxon y descargarlo
|
||||
RUN mkdir -p /opt/saxon && \
|
||||
curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \
|
||||
curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
|
||||
curl -L -o /opt/saxon/xmlresolver.jar https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar
|
||||
|
||||
# Extension functions jar
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ import javax.xml.transform.stream.StreamResult;
|
||||
import org.w3c.dom.Node;
|
||||
|
||||
public class CustomFunctions {
|
||||
private static final String NAMESPACE_URI = "http://www.tibco.com/bw/xslt/custom-functions";
|
||||
|
||||
public static String uuid() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
@@ -387,12 +389,12 @@ public class CustomFunctions {
|
||||
// Saxon HE integration
|
||||
// ---------------------------------------------------------------------
|
||||
// Saxon-HE doesn't support reflexive java: calls. To keep XSLT unchanged
|
||||
// (e.g., xmlns:java="java:com.xsltplayground.ext.CustomFunctions" and
|
||||
// java:uuid()), register integrated extension functions programmatically.
|
||||
// (e.g., xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions" and
|
||||
// tib:uuid()), register integrated extension functions programmatically.
|
||||
// Call CustomFunctions.registerAll(processor) once during initialization.
|
||||
|
||||
public static void registerAll(net.sf.saxon.s9api.Processor processor) {
|
||||
final String namespace = "java:" + CustomFunctions.class.getName();
|
||||
final String namespace = NAMESPACE_URI;
|
||||
|
||||
java.lang.reflect.Method[] methods = CustomFunctions.class.getDeclaredMethods();
|
||||
for (java.lang.reflect.Method m : methods) {
|
||||
|
||||
+59
-47
@@ -270,55 +270,67 @@ func main() {
|
||||
duration := time.Since(start).Milliseconds()
|
||||
log.Printf("transformation done in %dms", duration)
|
||||
|
||||
var traceEntries []TraceEntry
|
||||
var traceText string
|
||||
if req.Trace {
|
||||
// Load trace text from file (preferred) or stderr
|
||||
if tracePath != "" {
|
||||
if data, err := os.ReadFile(tracePath); err == nil {
|
||||
traceText = string(data)
|
||||
}
|
||||
}
|
||||
if traceText == "" {
|
||||
traceText = stderr.String()
|
||||
}
|
||||
var traceEntries []TraceEntry
|
||||
var traceText string
|
||||
if req.Trace {
|
||||
// Load trace text from file (preferred) or stderr
|
||||
if tracePath != "" {
|
||||
if data, err := os.ReadFile(tracePath); err == nil {
|
||||
traceText = string(data)
|
||||
log.Printf("trace file %s size=%d bytes", tracePath, len(traceText))
|
||||
} else {
|
||||
log.Printf("trace read error: %v", err)
|
||||
}
|
||||
}
|
||||
if traceText == "" {
|
||||
traceText = stderr.String()
|
||||
if traceText != "" {
|
||||
log.Printf("trace fallback from stderr size=%d bytes", len(traceText))
|
||||
}
|
||||
}
|
||||
|
||||
// Parse block-based variable traces and legacy single-line ones
|
||||
lines := strings.Split(traceText, "\n")
|
||||
capturing := false
|
||||
var currName string
|
||||
var buf []string
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "TRACE_VAR_START|") {
|
||||
capturing = true
|
||||
currName = strings.TrimPrefix(l, "TRACE_VAR_START|")
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "TRACE_VAR_END") {
|
||||
if capturing {
|
||||
value := strings.Join(buf, "\n")
|
||||
traceEntries = append(traceEntries, TraceEntry{Name: currName, Value: value})
|
||||
}
|
||||
capturing = false
|
||||
currName = ""
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
if capturing {
|
||||
buf = append(buf, l)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "TRACE_VAR|") {
|
||||
parts := strings.SplitN(l, "|", 3)
|
||||
if len(parts) == 3 {
|
||||
traceEntries = append(traceEntries, TraceEntry{Name: parts[1], Value: parts[2]})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse block-based variable traces and legacy single-line ones
|
||||
lines := strings.Split(traceText, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
capturing := false
|
||||
var currName string
|
||||
var buf []string
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "TRACE_DEBUG") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, l)
|
||||
if strings.HasPrefix(l, "TRACE_VAR_START|") {
|
||||
capturing = true
|
||||
currName = strings.TrimPrefix(l, "TRACE_VAR_START|")
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "TRACE_VAR_END") {
|
||||
if capturing {
|
||||
value := strings.Join(buf, "\n")
|
||||
traceEntries = append(traceEntries, TraceEntry{Name: currName, Value: value})
|
||||
}
|
||||
capturing = false
|
||||
currName = ""
|
||||
buf = nil
|
||||
continue
|
||||
}
|
||||
if capturing {
|
||||
buf = append(buf, l)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "TRACE_VAR|") {
|
||||
parts := strings.SplitN(l, "|", 3)
|
||||
if len(parts) == 3 {
|
||||
traceEntries = append(traceEntries, TraceEntry{Name: parts[1], Value: parts[2]})
|
||||
}
|
||||
}
|
||||
}
|
||||
traceText = strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText})
|
||||
c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText})
|
||||
})
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoadConfigAppliesEnvOverrides(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://env")
|
||||
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/creds.json")
|
||||
t.Setenv("SAXON_CLASSPATH", "env-classpath")
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "app.config")
|
||||
payload := `{
|
||||
"port": "3000",
|
||||
"saxon_classpath": "classpath",
|
||||
"database_url": "postgres://file",
|
||||
"firebase_credentials": "/tmp/file-creds.json"
|
||||
}`
|
||||
if err := os.WriteFile(cfgPath, []byte(payload), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL != "postgres://env" {
|
||||
t.Fatalf("expected env database url, got %s", cfg.DatabaseURL)
|
||||
}
|
||||
if cfg.FirebaseCredentials != "/tmp/creds.json" {
|
||||
t.Fatalf("expected env firebase creds, got %s", cfg.FirebaseCredentials)
|
||||
}
|
||||
if cfg.SaxonClasspath != "env-classpath" {
|
||||
t.Fatalf("expected env saxon classpath, got %s", cfg.SaxonClasspath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddlewareSetsHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware())
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
headers := rec.Result().Header
|
||||
if headers.Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatalf("missing CORS origin header")
|
||||
}
|
||||
if headers.Get("Access-Control-Allow-Methods") == "" {
|
||||
t.Fatalf("missing CORS methods header")
|
||||
}
|
||||
if headers.Get("Access-Control-Allow-Headers") == "" {
|
||||
t.Fatalf("missing CORS headers header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddlewareHandlesOptionsRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware())
|
||||
|
||||
handlerCalled := false
|
||||
router.Any("/test", func(c *gin.Context) {
|
||||
handlerCalled = true
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status 204, got %d", rec.Code)
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Fatalf("handler should not be called for OPTIONS requests")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
build: ./backend
|
||||
environment:
|
||||
VITE_GO_PRO: "false"
|
||||
XSLT_TRACE_DEBUG: "true"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
frontend:
|
||||
|
||||
+19
-6
@@ -1,14 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
escape_js_string() {
|
||||
# Escape backslashes, double quotes and newlines so runtime env stays valid JS
|
||||
printf '%s' "$1" | sed ':a;N;$!ba;s/\\/\\\\/g;s/\n/\\n/g;s/"/\\"/g'
|
||||
}
|
||||
|
||||
BACKEND_URL_ESC=$(escape_js_string "${VITE_BACKEND_URL}")
|
||||
GO_PRO_ESC=$(escape_js_string "${VITE_GO_PRO}")
|
||||
ADSENSE_CLIENT_ESC=$(escape_js_string "${VITE_ADSENSE_CLIENT}")
|
||||
ADSENSE_SLOT_ESC=$(escape_js_string "${VITE_ADSENSE_SLOT}")
|
||||
FIREBASE_CONFIG_ESC=$(escape_js_string "${VITE_FIREBASE_CONFIG}")
|
||||
GA_ID_ESC=$(escape_js_string "${VITE_GA_ID}")
|
||||
|
||||
# Write runtime environment variables for the frontend
|
||||
cat <<EOF >/usr/share/nginx/html/env.js
|
||||
window.env = {
|
||||
VITE_BACKEND_URL: "${VITE_BACKEND_URL}",
|
||||
VITE_GO_PRO: "${VITE_GO_PRO}",
|
||||
VITE_ADSENSE_CLIENT: "${VITE_ADSENSE_CLIENT}",
|
||||
VITE_ADSENSE_SLOT: "${VITE_ADSENSE_SLOT}",
|
||||
VITE_FIREBASE_CONFIG: "${VITE_FIREBASE_CONFIG}",
|
||||
VITE_GA_ID: "${VITE_GA_ID}"
|
||||
VITE_BACKEND_URL: "${BACKEND_URL_ESC}",
|
||||
VITE_GO_PRO: "${GO_PRO_ESC}",
|
||||
VITE_ADSENSE_CLIENT: "${ADSENSE_CLIENT_ESC}",
|
||||
VITE_ADSENSE_SLOT: "${ADSENSE_SLOT_ESC}",
|
||||
VITE_FIREBASE_CONFIG: "${FIREBASE_CONFIG_ESC}",
|
||||
VITE_GA_ID: "${GA_ID_ESC}"
|
||||
};
|
||||
EOF
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<meta property="og:url" content="https://xsltplayground.com/" />
|
||||
<meta property="og:image" content="/logo.svg" />
|
||||
<link rel="canonical" href="https://xsltplayground.com/" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script type="module" src="/env.js"></script>
|
||||
<script>
|
||||
if (window.env && window.env.VITE_ADSENSE_CLIENT) {
|
||||
|
||||
Generated
+1455
-6
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "xslt-playground",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -17,7 +18,11 @@
|
||||
"xml-formatter": "^3.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+1240
-316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "./App";
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
default: () => <div data-testid="monaco-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("ga-4-react", () => {
|
||||
return {
|
||||
default: class {
|
||||
initialize() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ result: "<root/>", duration_ms: 5 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.env = {
|
||||
VITE_BACKEND_URL: "",
|
||||
VITE_GA_ID: "",
|
||||
VITE_GO_PRO: "false",
|
||||
VITE_APP_VERSION: "test",
|
||||
VITE_NEWS_URL: "https://example.com/news",
|
||||
VITE_REPO_URL: "https://example.com/repo",
|
||||
};
|
||||
window.adsbygoogle = [];
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
delete window.env;
|
||||
});
|
||||
|
||||
describe("App bootstrap", () => {
|
||||
it("renders without crashing", async () => {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(screen.getByText(/xsltplayground\.com/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export default function Buymeacoffee() {
|
||||
useEffect(() => {
|
||||
const script = document.createElement("script");
|
||||
const div = document.getElementById("supportByBMC");
|
||||
script.setAttribute("data-name", "BMC-Widget");
|
||||
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
|
||||
script.setAttribute("data-id", "alexandrev");
|
||||
script.setAttribute("data-description", "Support me on Buy me a coffee!");
|
||||
script.setAttribute(
|
||||
"data-message",
|
||||
"Thank you for visiting my website. If this app has helped you in anyway, consider buying us a coffee. ✨😎",
|
||||
);
|
||||
script.setAttribute("data-color", "#FFDD00");
|
||||
script.setAttribute("data-position", "Right");
|
||||
script.setAttribute("data-x_margin", "18");
|
||||
script.setAttribute("data-y_margin", "18");
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
script.onload = function () {
|
||||
var evt = document.createEvent("Event");
|
||||
evt.initEvent("DOMContentLoaded", false, false);
|
||||
window.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
div.appendChild(script);
|
||||
}, []);
|
||||
|
||||
return <div id="supportByBMC"></div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const SCRIPT_ID = "bmc-widget";
|
||||
|
||||
export default function BuyMeACoffee() {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.setAttribute("data-name", "BMC-Widget");
|
||||
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
|
||||
script.setAttribute("data-id", "alexandrev");
|
||||
script.setAttribute("data-description", "Support me on Buy me a coffee!");
|
||||
script.setAttribute(
|
||||
"data-message",
|
||||
"Thanks for using XSLT Playground. If it helped you, feel free to buy me a coffee ☕",
|
||||
);
|
||||
script.setAttribute("data-color", "#FFDD00");
|
||||
script.setAttribute("data-position", "Right");
|
||||
script.setAttribute("data-x_margin", "24");
|
||||
script.setAttribute("data-y_margin", "24");
|
||||
script.async = true;
|
||||
containerRef.current.appendChild(script);
|
||||
|
||||
const handleLoad = () => {
|
||||
const evt = document.createEvent("Event");
|
||||
evt.initEvent("DOMContentLoaded", false, false);
|
||||
window.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
script.addEventListener("load", handleLoad);
|
||||
return () => {
|
||||
script.removeEventListener("load", handleLoad);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div id="supportByBMC" ref={containerRef} />;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export default function DataPipelineHeader({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
onAddParam,
|
||||
}) {
|
||||
return (
|
||||
<div className="params-header">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button params-collapse"
|
||||
title={collapsed ? "Show data pipeline" : "Hide data pipeline"}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? "Show data pipeline" : "Hide data pipeline"}
|
||||
>
|
||||
{collapsed ? "▶" : "▼"}
|
||||
</button>
|
||||
<div className="title">Data Pipeline</div>
|
||||
<div className="params-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onAddParam}
|
||||
title="Add new parameter"
|
||||
aria-label="Add new parameter"
|
||||
>
|
||||
➕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import DataPipelineHeader from "./DataPipelineHeader";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
describe("DataPipelineHeader", () => {
|
||||
it("calls toggle when collapse button clicked", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<DataPipelineHeader
|
||||
collapsed={false}
|
||||
onToggleCollapsed={onToggle}
|
||||
onAddParam={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /hide data pipeline/i }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls add handler when add button pressed", () => {
|
||||
const onAdd = vi.fn();
|
||||
render(
|
||||
<DataPipelineHeader
|
||||
collapsed
|
||||
onToggleCollapsed={vi.fn()}
|
||||
onAddParam={onAdd}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add new parameter/i }));
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const FEEDBACK_MAIL = "xsltplayground@alexandre-vazquez.cloud";
|
||||
const STORAGE_KEY = "feedbackPos";
|
||||
const MIN_MARGIN = 10;
|
||||
const DEFAULT_MARGIN = 24;
|
||||
const FALLBACK_WIDTH = 220;
|
||||
|
||||
export default function FeedbackWidget() {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("feedbackCollapsed") || "false");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const widgetRef = useRef(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
);
|
||||
const [viewportWidth, setViewportWidth] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
);
|
||||
const [position, setPosition] = useState(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (
|
||||
typeof parsed?.x === "number" &&
|
||||
typeof parsed?.y === "number"
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
if (
|
||||
typeof parsed?.right === "number" &&
|
||||
typeof parsed?.top === "number"
|
||||
) {
|
||||
return {
|
||||
x: Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth - parsed.right - FALLBACK_WIDTH,
|
||||
),
|
||||
y: Math.max(MIN_MARGIN, parsed.top),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [opensUp, setOpensUp] = useState(false);
|
||||
const resolvedPosition = useMemo(
|
||||
() => {
|
||||
if (position) return position;
|
||||
const fallbackHeight =
|
||||
viewportHeight || (typeof window !== "undefined" ? window.innerHeight : 0);
|
||||
return {
|
||||
x: DEFAULT_MARGIN,
|
||||
y: Math.max(DEFAULT_MARGIN, fallbackHeight - 220),
|
||||
};
|
||||
},
|
||||
[position, viewportHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handleResize = () => {
|
||||
setViewportHeight(window.innerHeight);
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("feedbackCollapsed", JSON.stringify(collapsed));
|
||||
} catch {}
|
||||
}, [collapsed]);
|
||||
|
||||
const clampWithinViewport = useCallback(
|
||||
(pos) => {
|
||||
if (
|
||||
!pos ||
|
||||
typeof window === "undefined" ||
|
||||
!widgetRef.current
|
||||
) {
|
||||
return pos;
|
||||
}
|
||||
const rect = widgetRef.current.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth - rect.width - MIN_MARGIN,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerHeight - rect.height - MIN_MARGIN,
|
||||
);
|
||||
return {
|
||||
x: Math.min(Math.max(MIN_MARGIN, pos.x), maxX),
|
||||
y: Math.min(Math.max(MIN_MARGIN, pos.y), maxY),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!position) return;
|
||||
const next = clampWithinViewport(position);
|
||||
if (!next) return;
|
||||
if (next.x !== position.x || next.y !== position.y) {
|
||||
setPosition(next);
|
||||
}
|
||||
}, [position, clampWithinViewport, viewportHeight, viewportWidth, collapsed]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
position ||
|
||||
typeof window === "undefined" ||
|
||||
typeof document === "undefined"
|
||||
)
|
||||
return;
|
||||
const widget = widgetRef.current;
|
||||
if (!widget) return;
|
||||
const footer = document.querySelector(".footer");
|
||||
const margin = DEFAULT_MARGIN;
|
||||
const viewport = window.innerHeight;
|
||||
const widgetRect = widget.getBoundingClientRect();
|
||||
let y = Math.max(margin, viewport - widgetRect.height - margin);
|
||||
|
||||
if (footer) {
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
if (footerRect.top < viewport) {
|
||||
y = Math.max(margin, footerRect.top - widgetRect.height - margin);
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ x: margin, y });
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const handleMove = (event) => {
|
||||
event.preventDefault();
|
||||
const widget = widgetRef.current;
|
||||
const widgetRect = widget?.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth -
|
||||
(widgetRect?.width ?? 0) -
|
||||
MIN_MARGIN,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerHeight -
|
||||
(widgetRect?.height ?? 0) -
|
||||
MIN_MARGIN,
|
||||
);
|
||||
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(MIN_MARGIN, event.clientX - offset.x),
|
||||
maxX,
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(MIN_MARGIN, event.clientY - offset.y),
|
||||
maxY,
|
||||
),
|
||||
});
|
||||
};
|
||||
const stop = () => setDragging(false);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", stop);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", stop);
|
||||
};
|
||||
}, [dragging, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging || !position) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(position));
|
||||
} catch {}
|
||||
}, [dragging, position]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!widgetRef.current || !viewportHeight) return;
|
||||
const rect = widgetRef.current.getBoundingClientRect();
|
||||
setOpensUp(rect.top + rect.height / 2 > viewportHeight / 2);
|
||||
}, [position, collapsed, viewportHeight]);
|
||||
|
||||
const startDrag = (event) => {
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setOffset({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const mailLink = `mailto:${FEEDBACK_MAIL}?subject=${encodeURIComponent(
|
||||
"xsltplayground feedback",
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
className={`feedback-widget ${collapsed ? "collapsed" : ""} ${opensUp ? "opens-up" : ""}`}
|
||||
style={{ left: resolvedPosition.x, top: resolvedPosition.y }}
|
||||
>
|
||||
<div
|
||||
className="feedback-header"
|
||||
onMouseDown={startDrag}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>Feedback</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={collapsed ? "Show feedback panel" : "Hide feedback panel"}
|
||||
>
|
||||
{collapsed ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="feedback-body">
|
||||
<p>Have an idea or found a glitch? I would love to hear from you.</p>
|
||||
<a className="feedback-link" href={mailLink}>
|
||||
✉️ Send feedback
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export default function TabsNav({
|
||||
tabs,
|
||||
activeId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onExport,
|
||||
onClear,
|
||||
}) {
|
||||
return (
|
||||
<div className="tabs-left">
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab ${isActive ? "active" : ""}`}
|
||||
>
|
||||
<div className="tab-tools">
|
||||
<button
|
||||
type="button"
|
||||
className="tab-icon"
|
||||
onClick={() => onExport?.(tab)}
|
||||
title="Export workspace"
|
||||
aria-label={`Export workspace ${index + 1}`}
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-icon"
|
||||
onClick={() => onClear?.(tab)}
|
||||
title="Clear workspace"
|
||||
aria-label={`Clear workspace ${index + 1}`}
|
||||
>
|
||||
🧹
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-button"
|
||||
onClick={() => onSelect?.(tab.id)}
|
||||
>
|
||||
{`Workspace ${index + 1}`}
|
||||
</button>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose?.(tab.id);
|
||||
}}
|
||||
title="Close workspace"
|
||||
aria-label={`Close workspace ${index + 1}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import TabsNav from "./TabsNav";
|
||||
|
||||
const sampleTabs = [
|
||||
{ id: "a" },
|
||||
{ id: "b" },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TabsNav", () => {
|
||||
it("invokes export handler for the selected tab button", () => {
|
||||
const onExport = vi.fn();
|
||||
render(
|
||||
<TabsNav
|
||||
tabs={sampleTabs}
|
||||
activeId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onExport={onExport}
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButtons = screen.getAllByRole("button", { name: /Export workspace/ });
|
||||
fireEvent.click(exportButtons[1]);
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
expect(onExport).toHaveBeenCalledWith(sampleTabs[1]);
|
||||
});
|
||||
|
||||
it("invokes clear handler for matching tab", () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<TabsNav
|
||||
tabs={sampleTabs}
|
||||
activeId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getAllByRole("button", { name: /Clear workspace/ })[0];
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onClear).toHaveBeenCalledWith(sampleTabs[0]);
|
||||
});
|
||||
});
|
||||
+27
-3
@@ -1,4 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<rect width="40" height="40" rx="6" fill="#007acc"/>
|
||||
<text x="20" y="26" text-anchor="middle" font-size="20" font-family="Arial" fill="white" font-weight="bold">XSL</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="logo-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#07162f" />
|
||||
<stop offset="50%" stop-color="#0c3264" />
|
||||
<stop offset="100%" stop-color="#1480ff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="logo-flow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe07d" />
|
||||
<stop offset="100%" stop-color="#52f2c5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="48" height="48" rx="10" fill="url(#logo-bg)" />
|
||||
<g stroke="#8edfff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.92">
|
||||
<path d="M15 10 L8.5 18 L15 26" />
|
||||
<path d="M15 22 L8.5 30 L15 38" />
|
||||
<path d="M33 10 L39.5 18 L33 26" />
|
||||
<path d="M33 22 L39.5 30 L33 38" />
|
||||
</g>
|
||||
<path d="M7 14h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
|
||||
<path d="M7 34h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
|
||||
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="url(#logo-flow)" />
|
||||
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="none" stroke="rgba(0, 0, 0, 0.3)" stroke-width="0.8" stroke-linejoin="round" />
|
||||
<path d="M23 18 V30" stroke="#fff3d4" stroke-width="1.5" stroke-linecap="round" />
|
||||
<circle cx="23" cy="18" r="1.5" fill="#fffcf1" />
|
||||
<circle cx="23" cy="30" r="1.5" fill="#fffcf1" />
|
||||
<circle cx="23" cy="24" r="2.3" fill="#051c38" opacity="0.45" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
+749
-56
@@ -7,13 +7,27 @@ body,
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #ddd;
|
||||
.news-link {
|
||||
color: #007acc;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.version-pill {
|
||||
border: 1px solid #007acc;
|
||||
color: #007acc;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version-pill:hover {
|
||||
background: #e6f2fb;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -23,30 +37,110 @@ body,
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: #fafafa;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
.tabs-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-end;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tabs-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-bottom: 2px solid #007acc;
|
||||
font-weight: bold;
|
||||
.tab.active {
|
||||
background: #fff;
|
||||
border-color: #ddd;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active .tab-button {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
border: 1px dashed #bbb;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.tab-import {
|
||||
border: 1px dashed #bbb;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.tab-tools {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-icon:hover,
|
||||
.tab.active .tab-icon {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle button,
|
||||
.toggle select {
|
||||
margin-right: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle .right-actions {
|
||||
@@ -55,20 +149,94 @@ body,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.2rem 0.8rem 0.2rem 0.45rem;
|
||||
border: 1px solid #cfdcf4;
|
||||
border-radius: 999px;
|
||||
background: #f0f6ff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trace-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.trace-toggle-box {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #4a85ff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
color: transparent;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-toggle-box::after {
|
||||
content: "✔";
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trace-toggle input:checked + .trace-toggle-box {
|
||||
background: #4a85ff;
|
||||
color: #fff;
|
||||
border-color: #4a85ff;
|
||||
}
|
||||
|
||||
.trace-toggle input:focus-visible + .trace-toggle-box {
|
||||
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.2);
|
||||
}
|
||||
|
||||
.trace-toggle-label {
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: #21426c;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #fee;
|
||||
color: #900;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #f3c2c2;
|
||||
border-left: 4px solid #d00;
|
||||
max-height: 30vh; /* limit height */
|
||||
overflow: auto; /* enable scroll when needed */
|
||||
max-height: 7rem;
|
||||
overflow: auto;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.error-box-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: bold;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.error-box-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Tabular error list */
|
||||
.error-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
@@ -76,21 +244,40 @@ body,
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 1.75rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
padding-right: 0.25rem;
|
||||
color: #d00;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-more {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #b55;
|
||||
}
|
||||
|
||||
.error-expand-button {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.25rem;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
@@ -110,40 +297,254 @@ body,
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.params {
|
||||
width: 30%;
|
||||
overflow-y: auto;
|
||||
flex: 0 0 auto;
|
||||
width: 320px;
|
||||
min-width: 220px;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.params-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
background: linear-gradient(180deg, #f9fbff 0%, #f1f4ff 100%);
|
||||
}
|
||||
|
||||
.param-card {
|
||||
border: 1px solid #dfe5fb;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f6f8ff 100%);
|
||||
box-shadow: 0 8px 22px rgba(15, 40, 94, 0.07);
|
||||
padding: 0.55rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.param-card.open {
|
||||
border-color: #c4d4ff;
|
||||
box-shadow: 0 14px 34px rgba(15, 40, 94, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.param-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.param-name-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid #d5dff7;
|
||||
border-radius: 999px;
|
||||
background: #f0f5ff;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.param-name-wrap:focus-within {
|
||||
border-color: #4a85ff;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.15);
|
||||
}
|
||||
|
||||
.param-toggle {
|
||||
flex: 0 0 2.2rem;
|
||||
border: none;
|
||||
border-right: 1px solid #d5dff7;
|
||||
background: transparent;
|
||||
color: #1f3a63;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.param-toggle.open {
|
||||
color: #21426c;
|
||||
background: #e1e9ff;
|
||||
}
|
||||
|
||||
.param-toggle:hover {
|
||||
color: #4a85ff;
|
||||
}
|
||||
|
||||
.param-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.25rem 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #1d3770;
|
||||
}
|
||||
|
||||
.param-name-input::placeholder {
|
||||
color: #6a7da8;
|
||||
}
|
||||
|
||||
.param-name-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.param-remove {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #f8bcbc !important;
|
||||
background: #fff5f5;
|
||||
color: #b03a3a;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 2px 6px rgba(176, 58, 58, 0.15);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.param-remove:hover {
|
||||
background: #ffe4e4;
|
||||
border-color: #f19999 !important;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.param-content {
|
||||
border-top: 1px solid #ecf0fb;
|
||||
padding-top: 0.45rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.param-editor {
|
||||
border: 1px solid #dfe5fb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 40, 94, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.param-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.param-footer .icon-button {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dfe5fb;
|
||||
background: #f7f9ff;
|
||||
padding: 0.3rem 0.65rem;
|
||||
box-shadow: 0 4px 10px rgba(15, 40, 94, 0.08);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.param-footer .icon-button:hover {
|
||||
background: #eaf0ff;
|
||||
border-color: #c7d6ff;
|
||||
}
|
||||
|
||||
.param-upload {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.params-header {
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f7f9fc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.params-header .title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.params-header button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
.params-header-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.params-collapse {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.params-collapsed {
|
||||
flex: 0 0 2.5rem;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: #fafafa;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
color: #536084;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px dashed #c4d4ff;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pane-divider {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
margin: 0 0.35rem;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pane-divider span {
|
||||
width: 4px;
|
||||
height: 60%;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 66, 108, 0.2);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.pane-divider:hover span,
|
||||
.pane-divider.dragging span {
|
||||
background: #4a85ff;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 70%;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
@@ -168,7 +569,10 @@ body,
|
||||
width: 30%;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-header {
|
||||
@@ -176,17 +580,42 @@ body,
|
||||
border-bottom: 1px solid #eee;
|
||||
font-weight: bold;
|
||||
background: #fafafa;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.trace-header-actions {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.trace-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
width: 30%;
|
||||
width: auto;
|
||||
vertical-align: top;
|
||||
padding: 0.25rem;
|
||||
font-family: monospace;
|
||||
color: #333;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
@@ -194,31 +623,179 @@ body,
|
||||
|
||||
.trace-value {
|
||||
vertical-align: top;
|
||||
padding: 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trace-cell {
|
||||
position: relative;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.trace-preview {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow: auto;
|
||||
max-height: 6rem;
|
||||
}
|
||||
|
||||
.trace-name-preview {
|
||||
max-height: 4.5rem;
|
||||
}
|
||||
|
||||
.trace-value-preview {
|
||||
max-height: 9rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
padding: 0.75rem 0.75rem 0.5rem;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
color: #f6f6f6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.trace-hover-tooltip pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.trace-hover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip .icon-button {
|
||||
color: #f6f6f6;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip .icon-button:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.trace-raw-block {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: #f7f7f9;
|
||||
border: 1px solid #e2e2e4;
|
||||
border-radius: 4px;
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
overflow: auto;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.trace-empty {
|
||||
padding: 0.5rem;
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.trace-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.trace-divider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.trace-divider:hover::after {
|
||||
background: rgba(0, 122, 204, 0.6);
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
|
||||
.result {
|
||||
height: 40vh;
|
||||
border-top: 1px solid #ddd;
|
||||
background: #fff;
|
||||
padding: 1.75rem 0.5rem 0.75rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.banner {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #f0f0f0;
|
||||
border-top: 1px solid #ddd;
|
||||
.result-editor-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.result-resizer {
|
||||
flex: 0 0 auto;
|
||||
height: 0.75rem;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f6f8fb;
|
||||
border-top: 1px solid #e1e7f0;
|
||||
border-bottom: 1px solid #dfe5ef;
|
||||
}
|
||||
|
||||
.result-resizer span {
|
||||
width: 3rem;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: #c2ccdc;
|
||||
}
|
||||
|
||||
.result-resizer.dragging {
|
||||
background: #e0ecfc;
|
||||
}
|
||||
|
||||
.result-resizer.dragging span {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -229,6 +806,40 @@ body,
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid #cfdcf4;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 1.9rem 0.35rem 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f0f6ff;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2321426C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-position: calc(100% - 0.95rem) center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 12px 8px;
|
||||
color: #21426c;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.version-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a85ff;
|
||||
box-shadow: 0 0 0 3px rgba(74, 133, 255, 0.15);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.version-select:hover {
|
||||
border-color: #adc5ff;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
@@ -238,8 +849,90 @@ body,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-format-button {
|
||||
.result-format-button,
|
||||
.result-reset-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: 0.25rem;
|
||||
}
|
||||
|
||||
.result-format-button {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.result-reset-button {
|
||||
right: 3rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.result-reset-button.active {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.feedback-widget {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feedback-widget.opens-up {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.feedback-widget.collapsed {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.feedback-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #007acc;
|
||||
color: white;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 10px 10px 0 0;
|
||||
cursor: grab;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.feedback-widget.opens-up .feedback-header {
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feedback-body p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feedback-link {
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
background: #fff1c1;
|
||||
color: #805d00;
|
||||
border: 1px solid #ffd970;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback-link:hover {
|
||||
background: #ffe189;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.js",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user