diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 5baa221..39b1b21 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -294,6 +294,8 @@ "info": "Info", "warn": "Warning", "error": "Error" - } + }, + "copy-to-clipboard": "Copy to clipboard", + "copied": "Copied!" } } diff --git a/frontend/src/store/logging/reducer.ts b/frontend/src/store/logging/reducer.ts index dfadd42..6a01261 100644 --- a/frontend/src/store/logging/reducer.ts +++ b/frontend/src/store/logging/reducer.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { main } from '@wailsapp/go/models'; -interface ProcessedLogEntry { +export interface ProcessedLogEntry { time: Date; caller: string; level: string; @@ -39,10 +39,12 @@ const loggingReducer = createSlice({ initialState, reducers: { loadedLogData(state, { payload }: PayloadAction) { - state.messages = payload.map(processEntry); + state.messages = payload + .map(processEntry) + .sort((a, b) => b.time.getTime() - a.time.getTime()); }, receivedEvent(state, { payload }: PayloadAction) { - state.messages = [...state.messages, processEntry(payload)]; + state.messages = [processEntry(payload), ...state.messages]; }, clearedEvents(state) { state.messages = []; diff --git a/frontend/src/ui/components/LogViewer.tsx b/frontend/src/ui/components/LogViewer.tsx index 789fbe4..803eb07 100644 --- a/frontend/src/ui/components/LogViewer.tsx +++ b/frontend/src/ui/components/LogViewer.tsx @@ -1,4 +1,4 @@ -import { Cross2Icon } from '@radix-ui/react-icons'; +import { ClipboardCopyIcon, Cross2Icon } from '@radix-ui/react-icons'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -13,7 +13,11 @@ import { MultiToggle, MultiToggleItem, styled, + theme, } from '../theme'; +import { ProcessedLogEntry } from '../../store/logging/reducer'; +import Scrollbar from './utils/Scrollbar'; +import { delay } from '../../lib/time-utils'; const Floating = styled('div', { position: 'fixed', @@ -22,6 +26,7 @@ const Floating = styled('div', { display: 'flex', gap: '3px', zIndex: 10, + transition: 'all 100ms', }); const LogBubble = styled('div', { @@ -34,6 +39,10 @@ const LogBubble = styled('div', { lineHeight: '0.7rem', fontSize: '0.7rem', cursor: 'pointer', + opacity: '0.5', + '&:hover': { + opacity: '1', + }, variants: { level: { info: {}, @@ -57,8 +66,14 @@ const emptyFilter = { type LogLevel = keyof typeof emptyFilter; const levels: LogLevel[] = ['info', 'warn', 'error']; -interface LogDialogProps { - initialFilter: LogLevel; +function isSupportedLevel(level: string): level is LogLevel { + return (levels as string[]).includes(level); +} + +function formatTime(time: Date): string { + return [time.getHours(), time.getMinutes(), time.getSeconds()] + .map((x) => x.toString().padStart(2, '0')) + .join(':'); } const LevelToggle = styled(MultiToggleItem, { @@ -87,6 +102,180 @@ const LevelToggle = styled(MultiToggleItem, { }, }); +interface LogItemProps { + data: ProcessedLogEntry; +} + +const LogEntryContainer = styled('div', { + borderRadius: theme.borderRadius.form, + backgroundColor: '$gray4', + display: 'grid', + gridTemplateColumns: '75px 1fr', + fontSize: '0.9em', + variants: { + level: { + info: {}, + warn: { + backgroundColor: '$yellow4', + }, + error: { + backgroundColor: '$red6', + }, + }, + }, +}); +const LogTime = styled('div', { + backgroundColor: '$gray6', + gridColumn: '1', + gridRow: '1/3', + padding: '0.2rem 0.5rem', + textAlign: 'center', + color: '$gray11', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderTopLeftRadius: theme.borderRadius.form, + borderBottomLeftRadius: theme.borderRadius.form, + variants: { + level: { + info: {}, + warn: { + color: '$yellow11', + backgroundColor: '$yellow6', + }, + error: { + color: '$red11', + backgroundColor: '$red7', + }, + }, + }, +}); +const LogMessage = styled('div', { + gridColumn: '2', + padding: '0.4rem 0.5rem', +}); +const LogActions = styled('div', { + gridColumn: '3', + padding: '0.4rem 0.5rem 0', + '&:hover': { + color: '$gray12', + cursor: 'pointer', + }, + color: '$gray10', + variants: { + level: { + info: {}, + warn: { + '&:hover': { + color: '$yellow11', + }, + }, + error: { + '&:hover': { + color: '$red11', + }, + }, + }, + }, +}); +const LogDetails = styled('div', { + gridRow: '2', + gridColumn: '2/4', + display: 'flex', + gap: '1rem', + fontSize: '0.7em', + color: '$gray11', + backgroundColor: '$gray3', + padding: '0.2rem 0.3rem 0.1rem 0.5rem', + borderBottomRightRadius: theme.borderRadius.form, + borderBottomLeftRadius: theme.borderRadius.form, + variants: { + level: { + info: {}, + warn: { + backgroundColor: '$yellow3', + }, + error: { + backgroundColor: '$red4', + }, + }, + }, +}); +const LogDetailItem = styled('div', { + display: 'flex', + gap: '0.5rem', +}); +const LogDetailKey = styled('div', { + color: '$teal10', + variants: { + level: { + info: {}, + warn: { + color: '$yellow11', + }, + error: { + color: '$red11', + }, + }, + }, +}); +const LogDetailValue = styled('div', { flex: '1' }); + +function LogItem({ data }: LogItemProps) { + const { t } = useTranslation(); + const levelStyle = isSupportedLevel(data.level) ? data.level : null; + const details = Object.entries(data.data).filter(([key]) => key.length > 1); + const [copied, setCopied] = useState(false); + const copyToClipboard = async () => { + await navigator.clipboard.writeText(JSON.stringify(data.data)); + setCopied(true); + await delay(2000); + setCopied(false); + }; + return ( + + {formatTime(data.time)} + {data.message} + + {copied ? ( + {t('logging.copied')} + ) : ( + { + void copyToClipboard(); + }} + > + + + )} + + {details.length > 0 ? ( + + {details.map(([key, value]) => ( + + {key} + {JSON.stringify(value)} + + ))} + + ) : null} + + ); +} + +const LogEntriesContainer = styled('div', { + display: 'flex', + flexDirection: 'column', + gap: '5px', +}); + +interface LogDialogProps { + initialFilter: LogLevel; +} + function LogDialog({ initialFilter }: LogDialogProps) { const logEntries = useSelector((state: RootState) => state.logging.messages); const [filter, setFilter] = useState({ @@ -105,11 +294,22 @@ function LogDialog({ initialFilter }: LogDialogProps) { return acc; }, {} as Record); + const filtered = logEntries.filter( + (entry) => entry.level in filter && filter[entry.level], + ); + return ( - - + + {t('logging.dialog-title')} -

+ + + {filtered.map((entry) => ( + + ))} + +
); diff --git a/frontend/src/ui/pages/Strimertul.tsx b/frontend/src/ui/pages/Strimertul.tsx index 06f2423..1dafa07 100644 --- a/frontend/src/ui/pages/Strimertul.tsx +++ b/frontend/src/ui/pages/Strimertul.tsx @@ -23,7 +23,8 @@ const gradientAnimation = keyframes({ const LogoPic = styled('div', { minHeight: '170px', - width: '270px', + width: '220px', + marginRight: '10px', maskImage: `url(${logo as string})`, maskRepeat: 'no-repeat', maskPosition: 'center', @@ -47,7 +48,7 @@ const LogoPic = styled('div', { const LogoName = styled('h1', { fontSize: '40pt', fontWeight: 200, - textAlign: 'center', + textAlign: 'left', '@medium': { fontSize: '80pt', }, @@ -83,9 +84,9 @@ export default function StrimertulPage(): React.ReactElement { const [debugCount, setDebugCount] = useState(0); const countForDebug = () => { if (debugCount < 5) { - setDebugCount(debugCount+1); + setDebugCount(debugCount + 1); } else { - navigate("/debug"); + navigate('/debug'); } };