finish readme and prepare release

This commit is contained in:
fccview
2025-11-13 15:57:31 +00:00
parent 1b6f5b6e34
commit feeb56ece8
27 changed files with 324 additions and 79 deletions

View File

@@ -9,6 +9,7 @@
- [Using Docker (Recommended)](#using-docker-recommended)
- [API](#api)
- [Single Sign-On (SSO) with OIDC](#single-sign-on-sso-with-oidc)
- [Localization](#localization)
- [Local Development](#local-development)
- [Environment Variables](howto/ENV_VARIABLES.md)
- [Authentication](#authentication)
@@ -68,8 +69,8 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
</p>
<div align="center">
<img width="500px" src="screenshots/jobs-view.png">
<img width="500px" src="screenshots/scripts-view.png" />
<img width="500px" src="screenshots/home.png">
<img width="500px" src="screenshots/live-running.png" />
</div>
<a id="quick-start"></a>
@@ -108,7 +109,7 @@ services:
init: true
```
**📖 For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
📖 **For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
<a id="api"></a>
@@ -126,6 +127,14 @@ services:
📖 **For the complete SSO documentation, see [howto/SSO.md](howto/SSO.md)**
<a id="localization"></a>
## Localization
`cr*nmaster` officially support [some languages](app/_transations) and allows you to create your custom translations locally on your own machine.
📖 **For the complete Translations documentation, see [howto/TRANSLATIONS.md](howto/TRANSLATIONS.md)**
### ARM64 Support
The application supports both AMD64 and ARM64 architectures:
@@ -281,7 +290,7 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
https://your-domain.com/api/cronjobs
```
For complete API documentation with examples, see **[README_API.md](README_API.md)**
For complete API documentation with examples, see **[howto/API.md](howto/API.md)**
### Available Endpoints
@@ -484,10 +493,9 @@ The application uses standard cron format: `* * * * *`
## Contributing
1. Fork the repository
2. Create a feature branch
2. Create a feature branch from the `develop` branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
4. Submit a pull request to the `develop` branch
## Community shouts

View File

@@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
onNewTaskClick={() => setIsNewCronModalOpen(true)}
/>
) : (
<div className="space-y-3 max-h-[55vh] overflow-y-auto">
<div className="space-y-3 max-h-[55vh] overflow-y-auto dropdown-overflow-fix">
{loadedSettings ? (
filteredJobs.map((job) =>
minimalMode ? (

View File

@@ -2,22 +2,35 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/app/_components/GlobalComponents/Cards/Card";
import { Lock, Eye, EyeOff, Shield } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/app/_components/GlobalComponents/Cards/Card";
import { Lock, Eye, EyeOff, Shield, AlertTriangle } from "lucide-react";
interface LoginFormProps {
hasPassword?: boolean;
hasOIDC?: boolean;
version?: string;
}
export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormProps) => {
export const LoginForm = ({
hasPassword = false,
hasOIDC = false,
version,
}: LoginFormProps) => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const t = useTranslations();
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -38,10 +51,10 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
if (result.success) {
router.push("/");
} else {
setError(result.message || "Login failed");
setError(result.message || t("login.loginFailed"));
}
} catch (error) {
setError("An error occurred. Please try again.");
setError(t("login.genericError"));
} finally {
setIsLoading(false);
}
@@ -58,17 +71,31 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-primary" />
</div>
<CardTitle>Welcome to Cr*nMaster</CardTitle>
<CardTitle>{t("login.welcomeTitle")}</CardTitle>
<CardDescription>
{hasPassword && hasOIDC
? "Sign in with password or SSO"
? t("login.signInWithPasswordOrSSO")
: hasOIDC
? "Sign in with SSO"
: "Enter your password to continue"}
? t("login.signInWithSSO")
: t("login.enterPasswordToContinue")}
</CardDescription>
</CardHeader>
<CardContent>
{!hasPassword && !hasOIDC && (
<div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-md">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-amber-700 dark:text-amber-400">
<div className="font-medium">
{t("login.authenticationNotConfigured")}
</div>
<div className="mt-1">{t("login.noAuthMethodsEnabled")}</div>
</div>
</div>
</div>
)}
<div className="space-y-4">
{hasPassword && (
<form onSubmit={handlePasswordSubmit} className="space-y-4">
@@ -77,7 +104,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
placeholder={t("login.enterPassword")}
className="pr-10"
required
disabled={isLoading}
@@ -101,7 +128,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
className="w-full"
disabled={isLoading || !password.trim()}
>
{isLoading ? "Signing in..." : "Sign In"}
{isLoading ? t("login.signingIn") : t("login.signIn")}
</Button>
</form>
)}
@@ -113,7 +140,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
{t("login.orContinueWith")}
</span>
</div>
</div>
@@ -128,7 +155,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
disabled={isLoading}
>
<Shield className="w-4 h-4 mr-2" />
{isLoading ? "Redirecting..." : "Sign in with SSO"}
{isLoading ? t("login.redirecting") : t("login.signInWithSSO")}
</Button>
)}
@@ -138,6 +165,14 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
</div>
)}
</div>
{version && (
<div className="mt-6 pt-4 border-t border-border/50">
<div className="text-center text-xs text-muted-foreground">
Cr*nMaster {t("common.version", { version })}
</div>
</div>
)}
</CardContent>
</Card>
);

View File

@@ -5,39 +5,38 @@ import { useRouter } from "next/navigation";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { LogOut } from "lucide-react";
export const LogoutButton = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
router.push("/login");
router.refresh();
}
} catch (error) {
console.error("Logout error:", error);
} finally {
setIsLoading(false);
}
};
if (response.ok) {
router.push("/login");
router.refresh();
}
} catch (error) {
console.error("Logout error:", error);
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
disabled={isLoading}
title="Logout"
>
<LogOut className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Logout</span>
</Button>
);
return (
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
disabled={isLoading}
title="Logout"
>
<LogOut className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Logout</span>
</Button>
);
};

View File

@@ -46,7 +46,7 @@ export const FiltersModal = ({
size="md"
>
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-4 min-h-[200px]">
<div>
<label className="text-sm font-medium text-foreground mb-2 block">
{t("cronjobs.filterByUser")}

View File

@@ -110,7 +110,7 @@ export const ScriptModal = ({
<Code className="h-4 w-4 text-primary" />
<h3 className="text-sm font-medium text-foreground">Snippets</h3>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex-1 overflow-y-auto min-h-0 !pr-0">
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
</div>
</div>
@@ -150,4 +150,4 @@ export const ScriptModal = ({
</form>
</Modal>
);
}
};

View File

@@ -34,7 +34,9 @@ const categoryIcons = {
"Custom Scripts": Code,
};
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
export const BashSnippetHelper = ({
onInsertSnippet,
}: BashSnippetHelperProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
@@ -161,7 +163,7 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
)}
<div className="space-y-2 overflow-y-auto custom-scrollbar">
<div className="space-y-2 overflow-y-auto !pr-0 custom-scrollbar">
{filteredSnippets.map((snippet) => {
const Icon =
categoryIcons[snippet.category as keyof typeof categoryIcons] ||
@@ -243,4 +245,4 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
</div>
);
}
};

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle, X } from "lucide-react";
export const WrapperScriptWarning = () => {
const [isVisible, setIsVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const t = useTranslations();
useEffect(() => {
const dismissed = localStorage.getItem("wrapper-warning-dismissed");
if (dismissed === "true") {
setIsLoading(false);
return;
}
checkWrapperScriptModification();
}, []);
const checkWrapperScriptModification = async () => {
try {
const response = await fetch("/api/system/wrapper-check");
if (response.ok) {
const data = await response.json();
setIsVisible(data.modified);
}
} catch (error) {
console.error("Failed to check wrapper script:", error);
} finally {
setIsLoading(false);
}
};
const dismissWarning = () => {
setIsVisible(false);
localStorage.setItem("wrapper-warning-dismissed", "true");
};
if (isLoading || !isVisible) {
return null;
}
return (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-400">
{t("warnings.wrapperScriptModified")}
</h3>
<p className="text-sm text-amber-700 dark:text-amber-500 mt-1">
{t("warnings.wrapperScriptModifiedDescription")}
</p>
</div>
</div>
<button
onClick={dismissWarning}
className="text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 transition-colors ml-4"
aria-label="Dismiss warning"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};

View File

@@ -72,8 +72,9 @@ 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 ? "bg-accent text-accent-foreground" : ""
}`}
>
{user}
</button>
@@ -82,4 +83,4 @@ export const UserSwitcher = ({
)}
</div>
);
}
};

View File

@@ -4,6 +4,8 @@ import { useState, useRef, useEffect, ReactNode } from "react";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { MoreVertical } from "lucide-react";
const DROPDOWN_HEIGHT = 200; // Approximate max height of dropdown
interface DropdownMenuItem {
label: string;
icon?: ReactNode;
@@ -28,9 +30,21 @@ export const DropdownMenu = ({
onOpenChange,
}: DropdownMenuProps) => {
const [isOpen, setIsOpen] = useState(false);
const [positionAbove, setPositionAbove] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleOpenChange = (open: boolean) => {
if (open && triggerRef.current) {
// Calculate if dropdown should be positioned above or below
const rect = triggerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// Position above if there's not enough space below
setPositionAbove(spaceBelow < DROPDOWN_HEIGHT && spaceAbove > spaceBelow);
}
setIsOpen(open);
onOpenChange?.(open);
};
@@ -72,6 +86,7 @@ export const DropdownMenu = ({
return (
<div className="relative inline-block" ref={dropdownRef}>
<Button
ref={triggerRef}
variant="outline"
size="sm"
onClick={() => handleOpenChange(!isOpen)}
@@ -84,7 +99,11 @@ export const DropdownMenu = ({
</Button>
{isOpen && (
<div className="absolute right-0 mt-2 w-56 rounded-lg border border-border/50 bg-background shadow-lg z-50 overflow-hidden">
<div
className={`absolute right-0 w-56 rounded-lg border border-border/50 bg-background shadow-lg z-[9999] overflow-hidden ${
positionAbove ? "bottom-full mb-2" : "top-full mt-2"
}`}
>
<div className="py-1">
{items.map((item, index) => (
<button
@@ -99,7 +118,9 @@ export const DropdownMenu = ({
: "text-foreground hover:bg-accent"
}`}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
{item.icon && (
<span className="flex-shrink-0">{item.icon}</span>
)}
<span className="flex-1 text-left">{item.label}</span>
</button>
))}

View File

@@ -13,6 +13,7 @@ interface ModalProps {
size?: "sm" | "md" | "lg" | "xl";
showCloseButton?: boolean;
preventCloseOnClickOutside?: boolean;
className?: string;
}
export const Modal = ({
@@ -23,6 +24,7 @@ export const Modal = ({
size = "md",
showCloseButton = true,
preventCloseOnClickOutside = false,
className = "",
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
@@ -90,10 +92,11 @@ export const Modal = ({
<div
ref={modalRef}
className={cn(
"relative w-full bg-card border border-border shadow-lg overflow-y-auto",
"relative w-full bg-card border border-border shadow-lg",
"max-h-[85vh]",
"sm:rounded-lg sm:max-h-[90vh] sm:w-full",
sizeClasses[size]
sizeClasses[size],
className
)}
>
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border sticky top-0 bg-card z-10">
@@ -110,8 +113,10 @@ export const Modal = ({
)}
</div>
<div className="p-4 sm:p-6">{children}</div>
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(80vh-100px)]">
{children}
</div>
</div>
</div>
);
}
};

View File

@@ -10,7 +10,8 @@
"cancel": "Cancel",
"close": "Close",
"refresh": "Refresh",
"loading": "Loading"
"loading": "Loading",
"version": "{version}"
},
"cronjobs": {
"cronJobs": "Cron Jobs",
@@ -143,5 +144,24 @@
"available": "Available",
"systemStatus": "System Status",
"lastUpdated": "Last updated"
},
"login": {
"welcomeTitle": "Welcome to Cr*nMaster",
"signInWithPasswordOrSSO": "Sign in with password or SSO",
"signInWithSSO": "Sign in with SSO",
"enterPasswordToContinue": "Enter your password to continue",
"authenticationNotConfigured": "Authentication Not Configured",
"noAuthMethodsEnabled": "Neither password authentication nor OIDC SSO is enabled. Please configure at least one authentication method in your environment variables to be able to log in.",
"enterPassword": "Enter password",
"signingIn": "Signing in...",
"signIn": "Sign In",
"redirecting": "Redirecting...",
"orContinueWith": "Or continue with",
"loginFailed": "Login failed",
"genericError": "An error occurred. Please try again."
},
"warnings": {
"wrapperScriptModified": "Wrapper Script Modified",
"wrapperScriptModifiedDescription": "Your cron-log-wrapper.sh script has been modified from the official version. This may affect logging functionality. Consider reverting to the official version or ensure your changes don't break the required format for log parsing."
}
}

View File

@@ -9,7 +9,8 @@
"optional": "Opzionale",
"cancel": "Annulla",
"refresh": "Aggiorna",
"close": "Chiudi"
"close": "Chiudi",
"version": "{version}"
},
"cronjobs": {
"cronJobs": "Operazioni Cron",
@@ -142,5 +143,24 @@
"available": "Disponibile",
"systemStatus": "Stato del Sistema",
"lastUpdated": "Ultimo aggiornamento"
},
"login": {
"welcomeTitle": "Benvenuto in Cr*nMaster",
"signInWithPasswordOrSSO": "Accedi con password o SSO",
"signInWithSSO": "Accedi con SSO",
"enterPasswordToContinue": "Inserisci la tua password per continuare",
"authenticationNotConfigured": "Autenticazione Non Configurata",
"noAuthMethodsEnabled": "Né l'autenticazione password né l'OIDC SSO sono abilitati. Si prega di configurare almeno un metodo di autenticazione nelle variabili d'ambiente per poter effettuare il login.",
"enterPassword": "Inserisci password",
"signingIn": "Accesso in corso...",
"signIn": "Accedi",
"redirecting": "Reindirizzamento...",
"orContinueWith": "Oppure continua con",
"loginFailed": "Accesso fallito",
"genericError": "Si è verificato un errore. Riprova."
},
"warnings": {
"wrapperScriptModified": "Script Wrapper Modificato",
"wrapperScriptModifiedDescription": "Il tuo script cron-log-wrapper.sh è stato modificato dalla versione ufficiale. Questo potrebbe influenzare la funzionalità di logging. Considera di ripristinare la versione ufficiale o assicurati che le tue modifiche non interrompano il formato richiesto per l'analisi dei log."
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import path from "path";
import { DATA_DIR } from "@/app/_consts/file";
export async function GET(request: NextRequest) {
try {
const officialScriptPath = path.join(
process.cwd(),
"app",
"_scripts",
"cron-log-wrapper.sh"
);
const dataScriptPath = path.join(
process.cwd(),
DATA_DIR,
"cron-log-wrapper.sh"
);
if (!existsSync(dataScriptPath)) {
return NextResponse.json({ modified: false });
}
const officialScript = readFileSync(officialScriptPath, "utf-8");
const dataScript = readFileSync(dataScriptPath, "utf-8");
const modified = officialScript !== dataScript;
return NextResponse.json({ modified });
} catch (error) {
console.error("Error checking wrapper script:", error);
return NextResponse.json(
{ error: "Failed to check wrapper script" },
{ status: 500 }
);
}
}

View File

@@ -324,3 +324,17 @@
margin-left: 4rem !important; /* 64px */
}
}
.dropdown-overflow-fix {
overflow: visible;
position: relative;
}
.dropdown-overflow-fix > * {
position: relative;
}
.dropdown-overflow-fix .dropdown-container,
.dropdown-overflow-fix [class*="dropdown"] {
overflow: visible !important;
}

View File

@@ -1,17 +1,28 @@
export const dynamic = "force-dynamic";
import { LoginForm } from "@/app/_components/FeatureComponents/LoginForm/LoginForm";
import { readFileSync } from "fs";
import path from "path";
export default async function LoginPage() {
const hasPassword = !!process.env.AUTH_PASSWORD;
const hasOIDC = process.env.SSO_MODE === "oidc";
const hasPassword = !!process.env.AUTH_PASSWORD;
const hasOIDC = process.env.SSO_MODE === "oidc";
return (
<div className="min-h-screen relative">
<div className="hero-gradient absolute inset-0 -z-10"></div>
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
<LoginForm hasPassword={hasPassword} hasOIDC={hasOIDC} />
</div>
</div>
);
// Read package.json to get version
const packageJsonPath = path.join(process.cwd(), "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const version = packageJson.version;
return (
<div className="min-h-screen relative">
<div className="hero-gradient absolute inset-0 -z-10"></div>
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
<LoginForm
hasPassword={hasPassword}
hasOIDC={hasOIDC}
version={version}
/>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/app/_components/FeatureComponents/Theme/ThemeTogg
import { LogoutButton } from "@/app/_components/FeatureComponents/LoginForm/LogoutButton";
import { ToastContainer } from "@/app/_components/GlobalComponents/UIElements/Toast";
import { PWAInstallPrompt } from "@/app/_components/FeatureComponents/PWA/PWAInstallPrompt";
import { WrapperScriptWarning } from "@/app/_components/FeatureComponents/System/WrapperScriptWarning";
import { getTranslations } from "@/app/_server/actions/translations";
import { SSEProvider } from "@/app/_contexts/SSEContext";
@@ -93,6 +94,7 @@ export default async function Home() {
<main className="lg:ml-80 transition-all duration-300 ml-0 sidebar-collapsed:lg:ml-16">
<div className="container mx-auto px-4 py-8 lg:px-8">
<WrapperScriptWarning />
<TabbedInterface cronJobs={cronJobs} scripts={scripts} />
</div>
</main>

BIN
screenshots/backup.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

BIN
screenshots/home.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

BIN
screenshots/logs.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 292 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB