15 Commits

Author SHA1 Message Date
fccview
64a75d265f next 16 update, security fixes and pwa update 2026-02-11 19:22:29 +00:00
fccview
f2fb381964 create tarball for next release and fix few minor issues, also add download logs 2026-02-11 18:48:51 +00:00
fccview
0ed4942a30 make image full width in readme 2026-01-01 15:54:54 +00:00
fccview
aeab383116 fix heading image in readme 2026-01-01 15:52:06 +00:00
fccview
f098ded0c4 Merge pull request #77 from fccview/develop
Lift off
2026-01-01 14:28:29 +00:00
fccview
3ac9a5ca30 finish readme with latest screenshots 2026-01-01 14:22:21 +00:00
fccview
c708c013f3 fix modal on mobile and add proper screenshots 2026-01-01 14:17:11 +00:00
fccview
fb6531d00d fix few small issues, improve logo, add favicon 2026-01-01 13:56:52 +00:00
fccview
46e0838792 make standalone and fix fonts to work well with the new aesthetic 2026-01-01 11:36:50 +00:00
fccview
72f1c0a66d update screenshots 2026-01-01 09:37:34 +00:00
fccview
1adad49020 update minimal 2026-01-01 09:30:47 +00:00
fccview
b2dc0a3cb3 Merge pull request #68 from fccview/develop
Lift off!!
2025-12-14 09:09:41 +00:00
fccview
4beb7053f7 Merge branch 'develop' 2025-11-20 07:26:04 +00:00
fccview
d6b6aff44e Merge pull request #64 from fccview/develop
fix api auth issue
2025-11-20 07:25:41 +00:00
fccview
0ab3358e28 Merge pull request #60 from fccview/develop
Lift Off!
2025-11-19 20:46:15 +00:00
62 changed files with 1599 additions and 2336 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: fccview

View File

@@ -1,4 +1,4 @@
name: Reusable Docker Build Logic
name: Builder
on:
workflow_call:

View File

@@ -1,4 +1,4 @@
name: Build and Publish Multi-Platform Docker Image
name: Build and Publish
on:
push:

48
.github/workflows/pr-checks.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: PR Checks
on:
pull_request:
branches: ["**"]
jobs:
validate-branch:
name: Validate Target Branch
runs-on: ubuntu-latest
steps:
- name: YOU SHALL NOT PASS
if: github.base_ref == 'main'
run: |
if [ "${{ github.head_ref }}" != "develop" ]; then
echo "ERROR: Pull requests to 'main' are not allowed."
echo "Current source branch: ${{ github.head_ref }}"
echo ""
echo "Please create a PR to 'develop' first, this will become a release candidate when merged into 'main' by a maintainer"
exit 1
fi
echo "Valid PR: develop → main"
- name: PR info
run: |
echo "PR validation passed"
echo "Source: ${{ github.head_ref }}"
echo "Target: ${{ github.base_ref }}"
typing:
name: Type and install checks
runs-on: ubuntu-latest
needs: validate-branch
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup the best engine ever
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "yarn"
- name: Install all dependencies
run: yarn install --frozen-lockfile
- name: This will totally fail if you only use AI
run: yarn tsc --noEmit

60
.github/workflows/prebuild-release.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Build and Release Prebuilt Tarball
on:
push:
tags: ["*"]
jobs:
build-prebuild:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup the best engine ever
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Bake cronmaster
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
run: yarn build
- name: Get version from tag
id: version
run: |
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Structure the prebuild stuff
run: |
mkdir -p prebuild-release/cronmaster/.next
cp -r .next/standalone/. prebuild-release/cronmaster/
cp -r .next/static prebuild-release/cronmaster/.next/static
if [ -f .next/BUILD_ID ]; then
cp .next/BUILD_ID prebuild-release/cronmaster/.next/BUILD_ID
fi
cp -r public prebuild-release/cronmaster/public
cp -r howto prebuild-release/cronmaster/howto
- name: Create tarball - tarball is a funny name
run: |
cd prebuild-release
tar -czf cronmaster_${{ steps.version.outputs.version }}_prebuild.tar.gz cronmaster
sha256sum cronmaster_${{ steps.version.outputs.version }}_prebuild.tar.gz > cronmaster_${{ steps.version.outputs.version }}_prebuild.tar.gz.sha256
- name: Attach to Release - pray it works
uses: softprops/action-gh-release@v1
with:
files: |
prebuild-release/cronmaster_*_prebuild.tar.gz
prebuild-release/cronmaster_*_prebuild.tar.gz.sha256
tag_name: ${{ steps.version.outputs.version }}

View File

@@ -1,22 +1,7 @@
FROM node:20-slim AS base
RUN apt-get update && apt-get install -y \
pciutils \
curl \
iputils-ping \
util-linux \
ca-certificates \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
@@ -42,26 +27,27 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 nextjs
RUN apk add --no-cache su-exec docker-cli pciutils curl iputils util-linux ca-certificates
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir -p /app/scripts /app/data /app/snippets && \
chown -R nextjs:nodejs /app/scripts /app/data /app/snippets
RUN mkdir -p /app/.next/cache && \
chown -R nextjs:nodejs /app/.next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/app ./app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["yarn", "start"]
USER nextjs
CMD ["node", "server.js"]

View File

@@ -1,8 +1,8 @@
<p align="center">
<img src="public/heading.png" width="400px">
<img src="public/heading.png">
</p>
## Table of Contents
## Quick links
- [Features](#features)
- [Quick Start](#quick-start)
@@ -18,9 +18,16 @@
- [Managing Cron Jobs](#managing-cron-jobs)
- [Job Execution Logging](#job-execution-logging)
- [Managing Scripts](#managing-scripts)
- [Technologies Used](#technologies-used)
---
<div align="center">
| Desktop | Mobile |
|---------|--------|
| ![Dark Mode Desktop](screenshots/home-dark.png) | ![Dark Mode Mobile](screenshots/home-dark-mobile.png) |
| ![Light Mode Desktop](screenshots/home-light.png) | ![Light Mode Mobile](screenshots/home-light-mobile.png) |
</div>
## Features
@@ -49,26 +56,13 @@
<br />
</p>
---
<br />
## Before we start
Hey there! 👋 Just a friendly heads-up: I'm a big believer in open source and love sharing my work with the community. Everything you find in my GitHub repos is and always will be 100% free. If someone tries to sell you a "premium" version of any of my projects while claiming to be me, please know that this is not legitimate. 🚫
If you find my projects helpful and want to fuel my late-night coding sessions with caffeine, I'd be super grateful for any support! ☕
<p align="center">
<a href="https://www.buymeacoffee.com/fccview">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy me a coffee" width="150">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy me a coffee" width="120">
</a>
</p>
<div align="center">
<img width="500px" src="screenshots/home.png">
<img width="500px" src="screenshots/live-running.png" />
</div>
---
<a id="quick-start"></a>

View File

@@ -236,7 +236,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<div className="p-2 bg-primary/10 ascii-border">
<ClockIcon className="h-5 w-5 text-primary" />
</div>
<div>
@@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
onNewTaskClick={() => setIsNewCronModalOpen(true)}
/>
) : (
<div className="space-y-4 max-h-[55vh] min-h-[55vh] overflow-y-auto tui-scrollbar pr-2">
<div className="space-y-4 max-h-[55vh] min-h-[55vh] overflow-y-auto tui-scrollbar pr-1">
{loadedSettings ? (
filteredJobs.map((job) =>
minimalMode ? (

View File

@@ -148,7 +148,7 @@ export const CronJobItem = ({
return (
<div
key={job.id}
className={`tui-card p-4 terminal-font transition-colors ${isDropdownOpen ? "relative z-10" : ""
className={`border border-border lg:tui-card p-4 terminal-font transition-colors ${isDropdownOpen ? "relative z-10" : ""
}`}
>
<div className="flex flex-col sm:flex-row sm:items-start gap-4">

View File

@@ -139,7 +139,7 @@ export const MinimalCronJobItem = ({
return (
<div
key={job.id}
className={`tui-card p-3 terminal-font transition-colors ${isDropdownOpen ? "relative z-10" : ""
className={`border border-border lg:tui-card p-3 terminal-font transition-colors ${isDropdownOpen ? "relative z-10" : ""
}`}
>
<div className="flex items-center gap-3">
@@ -238,7 +238,7 @@ export const MinimalCronJobItem = ({
size="sm"
onClick={() => onRun(job.id)}
disabled={runningJobId === job.id || job.paused}
className="btn-outline h-8 px-3"
className="btn-outline h-8 px-3 hidden md:flex"
title={t("cronjobs.runCronManually")}
aria-label={t("cronjobs.runCronManually")}
>
@@ -259,7 +259,7 @@ export const MinimalCronJobItem = ({
onPause(job.id);
}
}}
className="btn-outline h-8 px-3"
className="btn-outline h-8 px-3 hidden md:flex"
title={t("cronjobs.pauseCronJob")}
aria-label={t("cronjobs.pauseCronJob")}
>
@@ -280,7 +280,7 @@ export const MinimalCronJobItem = ({
onToggleLogging(job.id);
}
}}
className="btn-outline h-8 px-3"
className="btn-outline h-8 px-3 hidden md:flex"
title={
job.logsEnabled
? t("cronjobs.viewLogs")

View File

@@ -92,7 +92,7 @@ export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="absolute -right-3 top-[21.5vh] w-6 h-6 bg-background0 ascii-border items-center justify-center transition-colors z-40 hidden lg:flex"
className="sidebar-shrinker absolute -right-3 top-[21.5vh] w-6 h-6 bg-background0 ascii-border items-center justify-center transition-colors z-40 hidden lg:flex"
>
{isCollapsed ? (
<CaretRightIcon className="h-3 w-3" />
@@ -101,29 +101,11 @@ export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
)}
</button>
<div className="p-4 ascii-border !border-t-0 border-l-0 !border-r-0 bg-background0">
<div
className={cn(
"flex items-center gap-3",
isCollapsed && "lg:justify-center"
)}
>
<div className="p-2 bg-background0 ascii-border flex-shrink-0">
<HardDrivesIcon className="h-4 w-4" />
</div>
{(!isCollapsed || !isCollapsed) && (
<h2 className="text-sm font-semibold truncate terminal-font">
{t("sidebar.systemOverview")}
</h2>
)}
</div>
</div>
<div
className={cn(
"overflow-y-auto tui-scrollbar",
isCollapsed ? "lg:p-2" : "p-4",
"h-full lg:h-[calc(100vh-88px-80px)]"
"h-full lg:h-[calc(100vh-88px)]"
)}
>
{isCollapsed ? (

View File

@@ -28,7 +28,7 @@ export const TabbedInterface = ({
<div className="flex gap-2">
<button
onClick={() => setActiveTab("cronjobs")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium flex-1 justify-center terminal-font ${activeTab === "cronjobs"
className={`flex items-center gap-2 px-4 py-2 border border-transparent text-sm font-medium flex-1 justify-center terminal-font ${activeTab === "cronjobs"
? "bg-background0 ascii-border"
: "hover:ascii-border"
}`}
@@ -41,7 +41,7 @@ export const TabbedInterface = ({
</button>
<button
onClick={() => setActiveTab("scripts")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium flex-1 justify-center terminal-font ${activeTab === "scripts"
className={`flex items-center gap-2 px-4 py-2 border border-transparent text-sm font-medium flex-1 justify-center terminal-font ${activeTab === "scripts"
? "bg-background0 ascii-border"
: "hover:ascii-border"
}`}

View File

@@ -94,14 +94,14 @@ export const FiltersModal = ({
</Button>
{isScheduleDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background0 border border-border rounded-md shadow-lg z-50 min-w-[140px]">
<div className="absolute top-full left-0 p-1 right-0 mt-1 bg-background0 border border-border rounded-md shadow-lg z-50 min-w-[140px]">
<button
onClick={() => {
setLocalScheduleMode("cron");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${localScheduleMode === "cron"
? "bg-accent text-accent-foreground"
className={`w-full text-left px-3 border py-2 text-sm hover:border-border border-transparent transition-colors flex items-center gap-2 ${localScheduleMode === "cron"
? "border-border"
: ""
}`}
>
@@ -113,8 +113,8 @@ export const FiltersModal = ({
setLocalScheduleMode("human");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${localScheduleMode === "human"
? "bg-accent text-accent-foreground"
className={`w-full text-left px-3 py-2 border text-sm hover:border-border border-transparent transition-colors flex items-center gap-2 ${localScheduleMode === "human"
? "border-border"
: ""
}`}
>
@@ -126,8 +126,8 @@ export const FiltersModal = ({
setLocalScheduleMode("both");
setIsScheduleDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${localScheduleMode === "both"
? "bg-accent text-accent-foreground"
className={`w-full text-left px-3 py-2 border text-sm hover:border-border border-transparent transition-colors flex items-center gap-2 ${localScheduleMode === "both"
? "border-border"
: ""
}`}
>

View File

@@ -348,8 +348,8 @@ export const LiveLogModal = ({
</div>
)}
<div className="bg-black/90 dark:bg-black/60 p-4 max-h-[60vh] overflow-auto terminal-font ascii-border">
<pre className="text-xs font-mono text-status-success whitespace-pre-wrap break-words">
<div className="bg-background0 p-4 max-h-[60vh] overflow-auto terminal-font ascii-border">
<pre className="text-xs text-status-success whitespace-pre-wrap break-words">
{logContent || t("cronjobs.waitingForJobToStart")}
<div ref={logEndRef} />
</pre>

View File

@@ -3,8 +3,9 @@
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { FileTextIcon, TrashIcon, EyeIcon, XIcon, ArrowsClockwiseIcon, WarningCircleIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { FileTextIcon, TrashIcon, EyeIcon, XIcon, ArrowsClockwiseIcon, WarningCircleIcon, CheckCircleIcon, DownloadIcon } from "@phosphor-icons/react";
import { useTranslations } from "next-intl";
import { zipSync, strToU8 } from "fflate";
import {
getJobLogs,
getLogContent,
@@ -44,6 +45,7 @@ export const LogsModal = ({
const [logContent, setLogContent] = useState<string>("");
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [isLoadingContent, setIsLoadingContent] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [stats, setStats] = useState<{
count: number;
totalSize: number;
@@ -133,6 +135,28 @@ export const LogsModal = ({
}
};
const handleDownloadLogs = async () => {
if (logs.length === 0) return;
setIsDownloading(true);
try {
const files: Record<string, Uint8Array> = {};
for (const log of logs) {
const content = await getLogContent(jobId, log.filename);
files[log.filename] = strToU8(content);
}
const zipped = zipSync(files);
const blob = new Blob([zipped as unknown as ArrayBuffer], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${jobComment || jobId}_logs.zip`;
a.click();
URL.revokeObjectURL(url);
} finally {
setIsDownloading(false);
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
@@ -157,16 +181,29 @@ export const LogsModal = ({
return (
<Modal isOpen={isOpen} onClose={onClose} title={t("cronjobs.viewLogs")} size="xl">
<div className="flex flex-col h-[600px]">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-border">
<div>
<h3 className="font-semibold text-lg">{jobComment || jobId}</h3>
<div className="block sm:flex items-center justify-between mb-4 pb-4 border-b border-border">
<div className="min-w-0 mb-4 sm:mb-0">
<h3 className="font-semibold text-lg truncate">{jobComment || jobId}</h3>
{stats && (
<p className="text-sm text-muted-foreground">
{stats.count} {t("cronjobs.logs")} {stats.totalSizeMB} MB
</p>
)}
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-shrink-0">
<Button
onClick={handleDownloadLogs}
disabled={logs.length === 0 || isDownloading}
className="btn-primary glow-primary"
size="sm"
>
{isDownloading ? (
<ArrowsClockwiseIcon className="w-4 h-4 sm:mr-2 animate-spin" />
) : (
<DownloadIcon className="w-4 h-4 sm:mr-2" />
)}
<span className="hidden sm:inline">{t("cronjobs.downloadLog")}</span>
</Button>
<Button
onClick={loadLogs}
disabled={isLoadingLogs}
@@ -174,10 +211,10 @@ export const LogsModal = ({
size="sm"
>
<ArrowsClockwiseIcon
className={`w-4 h-4 mr-2 ${isLoadingLogs ? "animate-spin" : ""
className={`w-4 h-4 sm:mr-2 ${isLoadingLogs ? "animate-spin" : ""
}`}
/>
{t("common.refresh")}
<span className="hidden sm:inline">{t("common.refresh")}</span>
</Button>
{logs.length > 0 && (
<Button
@@ -185,15 +222,15 @@ export const LogsModal = ({
variant="destructive"
size="sm"
>
<TrashIcon className="w-4 h-4 mr-2" />
{t("cronjobs.deleteAll")}
<TrashIcon className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">{t("cronjobs.deleteAll")}</span>
</Button>
)}
</div>
</div>
<div className="flex-1 flex gap-4 overflow-hidden">
<div className="w-1/3 flex flex-col border-r border-border pr-4 overflow-hidden">
<div className="flex-1 flex flex-col sm:flex-row gap-4 overflow-hidden">
<div className="sm:w-1/3 flex flex-col sm:border-r border-b sm:border-b-0 border-border sm:pr-4 pb-4 sm:pb-0 overflow-hidden max-h-[40%] sm:max-h-none">
<h4 className="font-semibold mb-2">{t("cronjobs.logFiles")}</h4>
<div className="flex-1 overflow-y-auto space-y-2">
{isLoadingLogs ? (
@@ -288,8 +325,8 @@ export const LogsModal = ({
<div className="mt-4 pt-4 border-t border-border flex justify-end">
<Button onClick={onClose} className="btn-primary glow-primary">
<XIcon className="w-4 h-4 mr-2" />
{t("common.close")}
<XIcon className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">{t("common.close")}</span>
</Button>
</div>
</div>

View File

@@ -86,14 +86,15 @@ export const RestoreBackupModal = ({
size="xl"
>
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={onBackupAll}
className="btn-outline flex-1"
>
<DownloadIcon className="h-4 w-4 mr-2" />
{t("cronjobs.backupAll")}
<DownloadIcon className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t("cronjobs.backupAll")}</span>
<span className="sm:hidden">Backup</span>
</Button>
{backups.length > 0 && (
<Button
@@ -101,17 +102,19 @@ export const RestoreBackupModal = ({
onClick={handleRestoreAll}
className="btn-primary flex-1"
>
<UploadIcon className="h-4 w-4 mr-2" />
{t("cronjobs.restoreAll")}
<UploadIcon className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t("cronjobs.restoreAll")}</span>
<span className="sm:hidden">Restore</span>
</Button>
)}
<Button
variant="outline"
onClick={onRefresh}
className="btn-outline"
className="btn-outline sm:w-auto"
title={t("common.refresh")}
>
<ArrowsClockwiseIcon className="h-4 w-4" />
<span className="sm:hidden ml-2">Refresh</span>
</Button>
</div>
@@ -126,7 +129,72 @@ export const RestoreBackupModal = ({
key={backup.filename}
className="tui-card p-3 terminal-font"
>
<div className="flex items-center gap-3">
<div className="flex flex-col gap-3 lg:hidden">
<div className="flex items-center justify-between">
<code className="text-xs bg-background0 text-status-warning px-1.5 py-0.5 terminal-font ascii-border">
{backup.job.schedule}
</code>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
onRestore(backup.filename);
onClose();
}}
className="btn-outline h-8 px-3"
title={t("cronjobs.restoreThisBackup")}
>
<UploadIcon className="h-3 w-3" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(backup.filename)}
disabled={deletingFilename === backup.filename}
className="h-8 px-3"
title={t("cronjobs.deleteBackup")}
>
{deletingFilename === backup.filename ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<TrashIcon className="h-3 w-3" />
)}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
{commandCopied === backup.filename && (
<CheckIcon className="h-3 w-3 text-status-success flex-shrink-0" />
)}
<pre
onClick={(e) => {
e.stopPropagation();
copyToClipboard(unwrapCommand(backup.job.command));
setCommandCopied(backup.filename);
setTimeout(() => setCommandCopied(null), 3000);
}}
className="max-w-full overflow-x-auto flex-1 cursor-pointer text-sm font-medium terminal-font bg-background1 px-2 py-1 ascii-border break-all"
title={unwrapCommand(backup.job.command)}
>
{unwrapCommand(backup.job.command)}
</pre>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
<span>{backup.job.user}</span>
</div>
<div className="flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
<span>{formatDate(backup.backedUpAt)}</span>
</div>
</div>
</div>
<div className="hidden lg:flex items-center gap-3">
<div className="flex-shrink-0">
<code className="text-xs bg-background0 text-status-warning px-1.5 py-0.5 terminal-font ascii-border">
{backup.job.schedule}
@@ -204,11 +272,11 @@ export const RestoreBackupModal = ({
</div>
)}
<div className="flex justify-between gap-2 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center sm:text-left">
{t("cronjobs.availableBackups")}: {backups.length}
</p>
<Button variant="outline" onClick={onClose} className="btn-outline">
<Button variant="outline" onClick={onClose} className="btn-outline w-full sm:w-auto">
{t("common.close")}
</Button>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type JSX } from "react";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
@@ -16,7 +16,6 @@ export const PWAInstallPrompt = (): JSX.Element | null => {
useEffect(() => {
if (typeof window === "undefined") return;
const onBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
};
const onAppInstalled = () => {
@@ -49,10 +48,10 @@ export const PWAInstallPrompt = (): JSX.Element | null => {
return (
<button
className="px-3 py-1 rounded-md border border-border bg-background/80 hover:bg-background/60"
className="px-3 py-2 ascii-border bg-background0 hover:bg-background1 transition-colors terminal-font text-sm"
onClick={onInstall}
>
Install App
Install
</button>
);
};

View File

@@ -13,7 +13,10 @@ export const ServiceWorkerRegister = (): null => {
r.scope.endsWith("/")
);
if (alreadyRegistered) return;
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
await navigator.serviceWorker.register("/serwist/sw.js", {
scope: "/",
updateViaCache: "none",
});
} catch (_err) {}
};
register();

View File

@@ -180,8 +180,8 @@ export const BashSnippetHelper = ({
{snippet.title}
</h4>
{snippet.source === "user" && (
<span className="inline-block px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded border border-green-200">
UserIcon
<span className="inline-block px-1.5 py-0.5 text-xs text-status-success border border-border">
User
</span>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { SunIcon, MoonIcon } from '@phosphor-icons/react';
export const ThemeToggle = () => {
const [mounted, setMounted] = useState(false);
@@ -18,9 +19,15 @@ export const ThemeToggle = () => {
return (
<button
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="px-3 py-2 ascii-border terminal-font text-sm bg-background0"
className="p-2 ascii-border bg-background0 hover:bg-background1 transition-colors"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'LIGHT' : 'DARK'}
{isDark ? (
<SunIcon size={20} weight="regular" className="text-foreground" />
) : (
<MoonIcon size={20} weight="regular" className="text-foreground" />
)}
</button>
);
};

View File

@@ -79,13 +79,13 @@ export const UserFilter = ({
</div>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background0 border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto tui-scrollbar">
<div className="absolute top-full left-0 right-0 p-1 mt-1 bg-background0 border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto tui-scrollbar">
<button
onClick={() => {
onUserChange(null);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${!selectedUser ? "bg-accent text-accent-foreground" : ""
className={`w-full text-left px-3 py-2 text-sm hover:border-border transition-colors ${!selectedUser ? "border border-border" : "border border-transparent"
}`}
>
{t("common.allUsers")}
@@ -97,7 +97,7 @@ export const UserFilter = ({
onUserChange(user);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
className={`w-full text-left px-3 py-2 text-sm border border-transparent hover:border-border transition-colors ${selectedUser === user ? "border border-border" : "border border-transparent"
}`}
>
{user}

View File

@@ -80,7 +80,7 @@ export const UserSwitcher = ({
onUserChange(user);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "border border-border" : "border border-transparent"
}`}
>
{user}

View File

@@ -7,10 +7,10 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'default', size = 'default', children, ...props }, ref) => {
const baseClasses = 'terminal-font ascii-border px-4 py-2 cursor-pointer inline-flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed';
const baseClasses = 'terminal-font border border-border px-4 py-2 cursor-pointer inline-flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
default: 'bg-background1 hover:bg-background2',
destructive: 'bg-status-error text-white hover:bg-status-error',
destructive: 'text-status-error hover:bg-status-error hover:text-white',
outline: 'bg-background0 hover:bg-background1',
secondary: 'bg-background2 hover:bg-background1',
ghost: 'border-0 bg-transparent hover:bg-background1',

View File

@@ -101,17 +101,17 @@ export const DropdownMenu = ({
className={`absolute right-0 w-56 ascii-border bg-background0 shadow-lg z-[9999] overflow-hidden terminal-font ${positionAbove ? "bottom-full mb-2" : "top-full mt-2"
}`}
>
<div className="py-1">
<div className="p-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${item.disabled
className={`w-full flex items-center border border-transparent gap-3 px-4 py-2 text-sm transition-colors ${item.disabled
? "opacity-50 cursor-not-allowed"
: item.variant === "destructive"
? "text-status-error hover:bg-background1"
: "hover:bg-background1"
? "text-status-error hover:border hover:border-border"
: "hover:border-border"
}`}
>
{item.icon && (

View File

@@ -52,7 +52,7 @@ export const Modal = ({
return (
<dialog
ref={dialogRef}
className={`ascii-border terminal-font bg-background0 ${sizeClasses[size]} max-w-[95vw] ${className}`}
className={`ascii-border terminal-font bg-background0 mobile-modal ${sizeClasses[size]} max-w-[95vw] ${className}`}
onClick={(e) => {
if (e.target === dialogRef.current && !preventCloseOnClickOutside) {
onClose();

View File

@@ -47,6 +47,7 @@
"logs": "logs",
"logFiles": "Log Files",
"logContent": "Log Content",
"downloadLog": "Download",
"selectLogToView": "Select a log file to view its content",
"noLogsFound": "No logs found for this job",
"confirmDeleteLog": "Are you sure you want to delete this log file?",

View File

@@ -46,6 +46,7 @@
"logs": "log",
"logFiles": "File",
"logContent": "Contenuto Log",
"downloadLog": "Scarica",
"selectLogToView": "Seleziona un file per visualizzarne il contenuto",
"noLogsFound": "Nessun log trovato per questa operazione",
"confirmDeleteLog": "Sei sicuro di voler eliminare questo file?",

View File

@@ -4,10 +4,8 @@ import { executeJob } from "@/app/_server/actions/cronjobs";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const authError = await requireAuth(request);
if (authError) return authError;

View File

@@ -8,10 +8,8 @@ import {
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const authError = await requireAuth(request);
if (authError) return authError;
@@ -40,10 +38,8 @@ export async function GET(
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function PATCH(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const authError = await requireAuth(request);
if (authError) return authError;
@@ -79,10 +75,8 @@ export async function PATCH(
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function DELETE(request: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const authError = await requireAuth(request);
if (authError) return authError;

View File

@@ -30,6 +30,45 @@
}
}
body {
font-family: 'IBM Plex Mono', monospace !important;
font-weight: 500 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Azeret Mono Variable', monospace !important;
}
p,
span,
a,
label,
input,
textarea,
button,
select,
option {
font-family: 'IBM Plex Mono', monospace !important;
font-weight: 500 !important;
}
pre {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
pre::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
@layer utilities {
.ascii-border {
border: 1px solid var(--box-border-color, var(--foreground2));
@@ -37,12 +76,18 @@
}
.border-border {
border-color: var(--box-border-color, var(--foreground2))!important;
border-color: var(--box-border-color, var(--foreground2)) !important;
}
.tui-scrollbar {
scrollbar-width: auto !important;
padding-right: 15px;
padding-right: 5px;
}
@media (min-width: 992px) {
.tui-scrollbar {
padding-right: 15px;
}
}
.tui-scrollbar::-webkit-scrollbar {
@@ -182,7 +227,6 @@
flex-direction: column;
}
/* Sidebar layout adjustment */
.no-sidebar main {
margin-left: 0 !important;
}
@@ -205,3 +249,31 @@
}
}
}
@media (max-width: 992px) {
.mobile-modal {
position: fixed;
bottom: 0;
top: auto;
width: 100% !important;
max-height: 90vh;
max-width: 100%;
margin: inherit;
}
}
.sidebar-shrinker {
z-index: 1;
}
.sidebar-shrinker:before {
content: '';
width: 0;
height: 0;
border-top: 6px solid var(--box-border-color, var(--foreground2));
border-right: 12px solid transparent;
position: absolute;
right: -1px;
bottom: -6px;
z-index: -1;
}

View File

@@ -4,6 +4,10 @@ import "@/app/globals.css";
import { ThemeProvider } from "@/app/_providers/ThemeProvider";
import { ServiceWorkerRegister } from "@/app/_components/FeatureComponents/PWA/ServiceWorkerRegister";
import { loadTranslationMessages } from "@/app/_server/actions/translations";
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
import '@fontsource-variable/azeret-mono';
import { NextIntlClientProvider } from "next-intl";
@@ -27,9 +31,9 @@ export const metadata: Metadata = {
telephone: false,
},
icons: {
icon: "/logo.png",
icon: "/favicon.png",
shortcut: "/logo.png",
apple: "/logo.png",
apple: "/logo-pwa.png",
},
};

View File

@@ -78,10 +78,12 @@ export default async function Home() {
<Logo size={48} showGlow={true} />
<div>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold terminal-font uppercase">
Cr*nMaster
Cr<span className="text-status-error">*</span>nMaster
</h1>
<p className="text-xs terminal-font flex items-center gap-2">
{t("common.version").replace("{version}", version)}
<a href={`https://github.com/fccview/cronmaster/releases/tag/${version}`} target="_blank" rel="noopener noreferrer">
{t("common.version").replace("{version}", version)}
</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,7 @@
import { createSerwistRoute } from "@serwist/turbopack";
const { GET } = createSerwistRoute({
swSrc: "app/sw.ts",
});
export { GET };

89
app/sw.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import {
Serwist,
NetworkFirst,
CacheableResponsePlugin,
ExpirationPlugin,
StaleWhileRevalidate,
CacheFirst,
} from "serwist";
import { cleanupOutdatedCaches } from "serwist/internal";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: WorkerGlobalScope;
const pageStrategy = new NetworkFirst({
cacheName: "pages",
matchOptions: { ignoreVary: true },
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({
maxEntries: 128,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
});
cleanupOutdatedCaches();
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: false,
runtimeCaching: [
{
matcher: ({ request }) => request.mode === "navigate",
handler: pageStrategy,
},
{
matcher: ({ request, sameOrigin }) =>
request.headers.get("RSC") === "1" && sameOrigin,
handler: pageStrategy,
},
{
matcher: /\/_next\/static.+\.js$/i,
handler: new CacheFirst({
cacheName: "static-js",
plugins: [
new ExpirationPlugin({
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: /\.(?:css|woff|woff2|ttf|eot|otf)$/i,
handler: new StaleWhileRevalidate({
cacheName: "static-assets",
plugins: [
new ExpirationPlugin({
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: new StaleWhileRevalidate({
cacheName: "images",
plugins: [
new ExpirationPlugin({
maxEntries: 128,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
},
],
});
serwist.addEventListeners();

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

View File

@@ -2,7 +2,7 @@
CronMaster supports internationalization (i18n) with both **unofficial custom translations** and **official translations** that can be contributed to the project.
## Table of Contents
## Quick links
- [Custom User Translations (Unofficial)](#custom-user-translations-unofficial)
- [Official Translations via Pull Request](#official-translations-via-pull-request)

3
next-env.d.ts vendored
View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,49 +0,0 @@
const withNextIntl = require('next-intl/plugin')('./app/i18n.ts');
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
buildExcludes: [/middleware-manifest\.json$/]
})
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config, { dev, isServer }) => {
config.resolve.alias = {
...config.resolve.alias,
'osx-temperature-sensor': false,
};
if (dev && !isServer) {
config.watchOptions = {
...config.watchOptions,
ignored: /node_modules/,
};
}
return config;
},
async headers() {
return [
{
source: '/manifest.json',
headers: [
{ key: 'Content-Type', value: 'application/manifest+json' },
],
},
{
source: '/sw.js',
headers: [
{ key: 'Service-Worker-Allowed', value: '/' },
{ key: 'Cache-Control', value: 'no-cache' },
],
},
]
},
}
module.exports = withNextIntl({
...withPWA(nextConfig)
});

39
next.config.mjs Normal file
View File

@@ -0,0 +1,39 @@
import { withSerwist } from "@serwist/turbopack";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./app/i18n.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
unoptimized: true,
},
webpack: (config, { dev, isServer }) => {
config.resolve.alias = {
...config.resolve.alias,
"osx-temperature-sensor": false,
};
if (dev && !isServer) {
config.watchOptions = {
...config.watchOptions,
ignored: /node_modules/,
};
}
return config;
},
async headers() {
return [
{
source: "/manifest.json",
headers: [
{ key: "Content-Type", value: "application/manifest+json" },
],
},
];
},
};
export default withSerwist(withNextIntl(nextConfig));

View File

@@ -1,12 +1,12 @@
{
"name": "cronjob-manager",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.6",
@@ -19,10 +19,12 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1",
"@fontsource-variable/azeret-mono": "^5.2.11",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@phosphor-icons/react": "^2.1.10",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@webtui/css": "^0.1.5",
"@webtui/theme-catppuccin": "^0.0.3",
@@ -32,28 +34,37 @@
"codemirror": "^6.0.2",
"cron-parser": "^5.3.0",
"cronstrue": "^3.2.0",
"fflate": "^0.8.2",
"jose": "^6.1.1",
"minimatch": "^10.0.3",
"next": "14.2.35",
"next": "16.1.6",
"next-intl": "^4.4.0",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"postcss": "^8",
"proper-lockfile": "^4.1.2",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-syntax-highlighter": "^15.6.1",
"systeminformation": "^5.27.11",
"serwist": "^9.5.5",
"systeminformation": "^5.27.14",
"tailwind-merge": "^2.0.0",
"tailwindcss": "^3.3.0",
"typescript": "^5"
},
"devDependencies": {
"@serwist/turbopack": "^9.5.5",
"@types/bcryptjs": "^2.4.6",
"@types/minimatch": "^6.0.0",
"eslint": "^8",
"eslint-config-next": "14.0.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"postcss-import": "^16.1.1"
},
"resolutions": {
"@isaacs/brace-expansion": "^5.0.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"lodash": "^4.17.23",
"prismjs": "^1.30.0",
"systeminformation": "^5.27.14"
}
}

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const middleware = async (request: NextRequest) => {
export const proxy = async (request: NextRequest) => {
const { pathname } = request.nextUrl;
if (

BIN
public/favicon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/logo-pwa.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -9,13 +9,13 @@
"orientation": "portrait-primary",
"icons": [
{
"src": "/logo.png",
"src": "/logo-pwa.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/logo.png",
"src": "/logo-pwa.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
screenshots/home-dark.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
screenshots/home-light.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

BIN
screenshots/logs-view.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

View File

@@ -3,7 +3,8 @@
"lib": [
"dom",
"dom.iterable",
"es6"
"es6",
"webworker"
],
"allowJs": true,
"skipLibCheck": true,
@@ -14,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -26,13 +27,15 @@
"@/*": [
"./*"
]
}
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"

3109
yarn.lock
View File

File diff suppressed because it is too large Load Diff