This commit is contained in:
Hymmel 2026-01-21 11:24:53 +01:00
parent 7f11569d26
commit 7baf11e115
13 changed files with 402 additions and 128 deletions

View file

@ -0,0 +1,63 @@
package de.itsolutions.ticketsystem.controller;
import de.itsolutions.ticketsystem.entity.Comment;
import de.itsolutions.ticketsystem.entity.Ticket;
import de.itsolutions.ticketsystem.entity.User;
import de.itsolutions.ticketsystem.repository.CommentRepository;
import de.itsolutions.ticketsystem.repository.TicketRepository;
import de.itsolutions.ticketsystem.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/tickets/{ticketId}/comments")
public class CommentController {
@Autowired
private CommentRepository commentRepository;
@Autowired
private TicketRepository ticketRepository;
@Autowired
private UserRepository userRepository;
@GetMapping
public List<Comment> getComments(@PathVariable Long ticketId) {
if (!ticketRepository.existsById(ticketId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found");
}
return commentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId);
}
@PostMapping
public Comment addComment(@PathVariable Long ticketId, @RequestBody Map<String, String> payload) {
String text = payload.get("text");
if (text == null || text.trim().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Text is required");
}
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found"));
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
Comment comment = new Comment();
comment.setText(text);
comment.setTicket(ticket);
comment.setAuthor(user);
return commentRepository.save(comment);
}
}

View file

@ -0,0 +1,42 @@
package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "comments")
@Data
@NoArgsConstructor
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "TEXT")
private String text;
@Column(nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@ManyToOne(optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User author;
@ManyToOne(optional = false)
@JoinColumn(name = "ticket_id", nullable = false)
private Ticket ticket;
public void setId(Long id) { this.id = id; }
public Long getId() { return id; }
public void setText(String text) { this.text = text; }
public String getText() { return text; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setAuthor(User author) { this.author = author; }
public User getAuthor() { return author; }
public void setTicket(Ticket ticket) { this.ticket = ticket; }
public Ticket getTicket() { return ticket; }
}

View file

@ -0,0 +1,9 @@
package de.itsolutions.ticketsystem.repository;
import de.itsolutions.ticketsystem.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByTicketIdOrderByCreatedAtAsc(Long ticketId);
}

View file

@ -35,7 +35,7 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4">
<div className="w-full max-w-[95%] mx-auto flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Cpu className="h-5 w-5 text-primary-foreground" />
@ -124,7 +124,7 @@ export function AppShell({ children }: AppShellProps) {
</div>
</header>
<main className="container px-4 py-8">{children}</main>
<main className="w-full max-w-[95%] mx-auto px-4 py-8">{children}</main>
</div>
)
}

View file

@ -0,0 +1,103 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useAuth } from "@/lib/auth-context"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import type { Comment } from "@/lib/types"
interface TicketCommentsProps {
ticketId: number
}
export function TicketComments({ ticketId }: TicketCommentsProps) {
const [comments, setComments] = useState<Comment[]>([])
const [newComment, setNewComment] = useState("")
const [loading, setLoading] = useState(false)
const { user, authHeader } = useAuth()
const API_URL = process.env.NEXT_PUBLIC_API_URL + "/api"
const fetchComments = useCallback(async () => {
if (!authHeader) return
try {
const res = await fetch(`${API_URL}/tickets/${ticketId}/comments`, {
headers: { "Authorization": authHeader }
})
if (res.ok) {
const data = await res.json()
setComments(data)
}
} catch (e) {
console.error(e)
}
}, [ticketId, authHeader])
useEffect(() => {
if (authHeader) {
fetchComments()
}
}, [fetchComments, authHeader])
const handlePost = async () => {
if (!authHeader || !newComment.trim()) return
setLoading(true)
try {
const res = await fetch(`${API_URL}/tickets/${ticketId}/comments`, {
method: "POST",
headers: {
"Authorization": authHeader,
"Content-Type": "application/json"
},
body: JSON.stringify({ text: newComment })
})
if (res.ok) {
setNewComment("")
fetchComments()
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
<h3 className="font-semibold">Comments</h3>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2">
{comments.length === 0 && <p className="text-sm text-muted-foreground">No comments yet.</p>}
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{comment.author.name ? comment.author.name.charAt(0) : '?'}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{comment.author.name}</span>
<span className="text-xs text-muted-foreground">{new Date(comment.createdAt).toLocaleString()}</span>
</div>
<div className="p-2 bg-muted/50 rounded-md">
<p className="text-sm text-foreground/90 whitespace-pre-wrap">{comment.text}</p>
</div>
</div>
</div>
))}
</div>
<div className="space-y-2 pt-2">
<Textarea
placeholder="Write a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px]"
/>
<div className="flex justify-end">
<Button onClick={handlePost} disabled={!newComment.trim() || loading}>
Post Comment
</Button>
</div>
</div>
</div>
)
}

View file

@ -12,7 +12,9 @@ import {
SelectValue,
} from "@/components/ui/select"
import { ArrowUpDown, ArrowDown, ArrowUp, Calendar, MapPin, FileText } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { TicketComments } from "./ticket-comments"
interface TicketTableProps {
tickets: Ticket[]
@ -23,18 +25,55 @@ interface TicketTableProps {
type SortDirection = "asc" | "desc"
export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate }: TicketTableProps) {
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const [sortConfig, setSortConfig] = useState<{ key: keyof Ticket | "room" | "owner"; direction: SortDirection }>({ key: "createdAt", direction: "desc" })
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<TicketStatus | "ALL">("ALL")
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
const sortedTickets = useMemo(() => {
return [...tickets].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime()
const dateB = new Date(b.createdAt).getTime()
return sortDirection === "desc" ? dateB - dateA : dateA - dateB
// Advanced Filtering & Sorting
const processedTickets = useMemo(() => {
let filtered = [...tickets]
// Search
if (search.trim()) {
const q = search.toLowerCase()
filtered = filtered.filter(t =>
t.title.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.room.name.toLowerCase().includes(q)
)
}
// Filter
if (statusFilter !== "ALL") {
filtered = filtered.filter(t => t.status === statusFilter)
}
// Sort
return filtered.sort((a, b) => {
let aValue: any = a[sortConfig.key as keyof Ticket]
let bValue: any = b[sortConfig.key as keyof Ticket]
// Nested keys
if (sortConfig.key === "room") {
aValue = a.room.name
bValue = b.room.name
} else if (sortConfig.key === "owner") {
aValue = a.owner.name
bValue = b.owner.name
}
if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1
return 0
})
}, [tickets, sortDirection])
}, [tickets, search, statusFilter, sortConfig])
const toggleSort = () => {
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"))
const handleSort = (key: keyof Ticket | "room" | "owner") => {
setSortConfig(current => ({
key,
direction: current.key === key && current.direction === "asc" ? "desc" : "asc"
}))
}
const formatDate = (dateString: string) => {
@ -47,97 +86,130 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate
})
}
if (tickets.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium">No tickets found</h3>
<p className="mt-2 text-sm text-muted-foreground">
There are no tickets to display at this time.
</p>
</div>
)
}
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="font-semibold">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Room
</div>
</TableHead>
<TableHead className="font-semibold">Title</TableHead>
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
<TableHead className="font-semibold">
<Button
variant="ghost"
size="sm"
onClick={toggleSort}
className="flex items-center gap-1 -ml-2 h-auto p-2 hover:bg-transparent"
>
<Calendar className="h-4 w-4" />
Date
{sortDirection === "desc" ? (
<ArrowDown className="h-3 w-3" />
) : (
<ArrowUp className="h-3 w-3" />
)}
</Button>
</TableHead>
<TableHead className="font-semibold">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedTickets.map((ticket) => (
<TableRow key={ticket.id} className="group">
<TableCell className="font-medium">{ticket.room.name}</TableCell>
<TableCell className="font-medium">{ticket.title}</TableCell>
<TableCell className="hidden md:table-cell max-w-xs">
<p className="truncate text-muted-foreground">{ticket.description}</p>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(ticket.createdAt)}</TableCell>
<TableCell>
{showStatusUpdate && onStatusUpdate ? (
<Select
value={ticket.status}
onValueChange={(value: TicketStatus) => onStatusUpdate(ticket.id, value)}
>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OPEN">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-status-open" />
Offen
</div>
</SelectItem>
<SelectItem value="IN_PROGRESS">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-status-progress" />
In Bearbeitung
</div>
</SelectItem>
<SelectItem value="CLOSED">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-status-done" />
Erledigt
</div>
</SelectItem>
</SelectContent>
</Select>
) : (
<StatusBadge status={ticket.status} />
)}
</TableCell>
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Input
placeholder="Search tickets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<div className="flex items-center gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Room {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div>
</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}>
Title {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</TableHead>
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Date {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div>
</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}>
Status {sortConfig.key === "status" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{processedTickets.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No results found.
</TableCell>
</TableRow>
) : (
processedTickets.map((ticket) => (
<TableRow
key={ticket.id}
className="group cursor-pointer hover:bg-muted/50"
onClick={() => setSelectedTicket(ticket)}
>
<TableCell className="font-medium">{ticket.room.name}</TableCell>
<TableCell className="font-medium">{ticket.title}</TableCell>
<TableCell className="hidden md:table-cell max-w-xs">
<p className="truncate text-muted-foreground">{ticket.description}</p>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(ticket.createdAt)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{showStatusUpdate && onStatusUpdate ? (
<Select
value={ticket.status}
onValueChange={(value: TicketStatus) => onStatusUpdate(ticket.id, value)}
>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OPEN"><StatusBadge status="OPEN" /></SelectItem>
<SelectItem value="IN_PROGRESS"><StatusBadge status="IN_PROGRESS" /></SelectItem>
<SelectItem value="CLOSED"><StatusBadge status="CLOSED" /></SelectItem>
</SelectContent>
</Select>
) : (
<StatusBadge status={ticket.status} />
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{selectedTicket && (
<Dialog open={!!selectedTicket} onOpenChange={(open) => !open && setSelectedTicket(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedTicket.title}</DialogTitle>
<DialogDescription>
{selectedTicket.room.name} {formatDate(selectedTicket.createdAt)} {selectedTicket.owner.name}
</DialogDescription>
</DialogHeader>
<div className="grid gap-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<div className="p-3 bg-muted rounded-md text-sm">
{selectedTicket.description}
</div>
</div>
<div>
<StatusBadge status={selectedTicket.status} />
</div>
<div className="border-t pt-4">
<TicketComments ticketId={selectedTicket.id} />
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

View file

@ -10,6 +10,7 @@ interface AuthContextType {
user: User | null
tickets: Ticket[]
rooms: Room[]
authHeader: string | null
login: (email: string, password: string) => Promise<boolean>
register: (user: Omit<User, "id" | "name"> & { firstname: string; lastname: string; password: string; roomIds?: number[] }) => Promise<boolean>
logout: () => void
@ -184,6 +185,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
tickets,
rooms,
authHeader,
login,
register,
logout,

View file

@ -25,3 +25,11 @@ export interface Ticket {
owner: User
}
export interface Comment {
id: number
text: string
createdAt: string
author: User
ticket: Ticket
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 B

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 568 B

View file

@ -1,26 +1 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: light) {
.background { fill: black; }
.foreground { fill: white; }
}
@media (prefers-color-scheme: dark) {
.background { fill: white; }
.foreground { fill: black; }
}
</style>
<g clip-path="url(#clip0_7960_43945)">
<rect class="background" width="180" height="180" rx="37" />
<g style="transform: scale(95%); transform-origin: center">
<path class="foreground"
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
<path class="foreground"
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
</g>
</g>
<defs>
<clipPath id="clip0_7960_43945">
<rect width="180" height="180" fill="white" />
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB