🔨 Clearning up the VolumeInformation tab

This commit is contained in:
2026-04-02 13:07:13 -04:00
parent 91592019c4
commit d7ab553120
4 changed files with 472 additions and 30 deletions

View File

@@ -0,0 +1,353 @@
---
name: typescript
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
---
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
Naming Conventions
Type Convention Example
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
Variables, Parameters, Functions lowerCamelCase userName, processData
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
Type Parameters Single letter or UpperCamelCase T, ResponseType
Naming Principles
Descriptive names, avoid ambiguous abbreviations
Treat acronyms as words: loadHttpUrl not loadHTTPURL
No prefixes like opt_ for optional parameters
No trailing underscores for private properties
Single-letter variables only when scope is <10 lines
Variable Declarations
// Always use const by default
const users = getUsers();
// Use let only when reassignment is needed
let count = 0;
count++;
// Never use var
// var x = 1; // WRONG
// One variable per declaration
const a = 1;
const b = 2;
// const a = 1, b = 2; // WRONG
Types and Interfaces
Prefer Interfaces Over Type Aliases
// Good: interface for object shapes
interface User {
id: string;
name: string;
email?: string;
}
// Avoid: type alias for object shapes
type User = {
id: string;
name: string;
};
// Type aliases OK for unions, intersections, mapped types
type Status = 'active' | 'inactive';
type Combined = TypeA & TypeB;
Type Inference
Leverage inference for trivially inferred types:
// Good: inference is clear
const name = 'Alice';
const items = [1, 2, 3];
// Good: explicit for complex expressions
const result: ProcessedData = complexTransformation(input);
Array Types
// Simple types: use T[]
const numbers: number[];
const names: readonly string[];
// Multi-dimensional: use T[][]
const matrix: number[][];
// Complex types: use Array<T>
const handlers: Array<(event: Event) => void>;
Null and Undefined
// Prefer optional fields over union with undefined
interface Config {
timeout?: number; // Good
// timeout: number | undefined; // Avoid
}
// Type aliases must NOT include |null or |undefined
type UserId = string; // Good
// type UserId = string | null; // WRONG
// May use == for null comparison (catches both null and undefined)
if (value == null) {
// handles both null and undefined
}
Types to Avoid
// Avoid any - use unknown instead
function parse(input: unknown): Data { }
// Avoid {} - use unknown, Record<string, T>, or object
function process(obj: Record<string, unknown>): void { }
// Use lowercase primitives
let name: string; // Good
// let name: String; // WRONG
// Never use wrapper objects
// new String('hello') // WRONG
Classes
Structure
class UserService {
// Fields first, initialized where declared
private readonly cache = new Map<string, User>();
private lastAccess: Date | null = null;
// Constructor with parameter properties
constructor(
private readonly api: ApiClient,
private readonly logger: Logger,
) {}
// Methods separated by blank lines
async getUser(id: string): Promise<User> {
// ...
}
private validateId(id: string): boolean {
// ...
}
}
Visibility
class Example {
// private by default, only use public when needed externally
private internalState = 0;
// readonly for properties never reassigned after construction
readonly id: string;
// Never use #private syntax - use TypeScript visibility
// #field = 1; // WRONG
private field = 1; // Good
}
Avoid Arrow Functions as Properties
class Handler {
// Avoid: arrow function as property
// handleClick = () => { ... };
// Good: instance method
handleClick(): void {
// ...
}
}
// Bind at call site if needed
element.addEventListener('click', () => handler.handleClick());
Static Methods
Never use this in static methods
Call on defining class, not subclasses
Functions
Prefer Function Declarations
// Good: function declaration for named functions
function processData(input: Data): Result {
return transform(input);
}
// Arrow functions when type annotation needed
const handler: EventHandler = (event) => {
// ...
};
Arrow Function Bodies
// Concise body only when return value is used
const double = (x: number) => x * 2;
// Block body when return should be void
const log = (msg: string) => {
console.log(msg);
};
Parameters
// Use rest parameters, not arguments
function sum(...numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}
// Destructuring for multiple optional params
interface Options {
timeout?: number;
retries?: number;
}
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
// ...
}
// Never name a parameter 'arguments'
Imports and Exports
Always Use Named Exports
// Good: named exports
export function processData() { }
export class UserService { }
export interface Config { }
// Never use default exports
// export default class UserService { } // WRONG
Import Styles
// Module import for large APIs
import * as fs from 'fs';
// Named imports for frequently used symbols
import { readFile, writeFile } from 'fs/promises';
// Type-only imports when only used as types
import type { User, Config } from './types';
Module Organization
Use modules, never namespace Foo { }
Never use require() - use ES6 imports
Use relative imports within same project
Avoid excessive ../../../
Control Structures
Always Use Braces
// Good
if (condition) {
doSomething();
}
// Exception: single-line if
if (condition) return early;
Loops
// Prefer for...of for arrays
for (const item of items) {
process(item);
}
// Use Object methods with for...of for objects
for (const [key, value] of Object.entries(obj)) {
// ...
}
// Never use unfiltered for...in on arrays
Equality
// Always use === and !==
if (a === b) { }
// Exception: == null catches both null and undefined
if (value == null) { }
Switch Statements
switch (status) {
case Status.Active:
handleActive();
break;
case Status.Inactive:
handleInactive();
break;
default:
// Always include default, even if empty
break;
}
Exception Handling
// Always throw Error instances
throw new Error('Something went wrong');
// throw 'error'; // WRONG
// Catch with unknown type
try {
riskyOperation();
} catch (e: unknown) {
if (e instanceof Error) {
logger.error(e.message);
}
throw e;
}
// Empty catch needs justification comment
try {
optional();
} catch {
// Intentionally ignored: fallback behavior handles this
}
Type Assertions
// Use 'as' syntax, not angle brackets
const input = value as string;
// const input = <string>value; // WRONG in TSX, avoid everywhere
// Double assertion through unknown when needed
const config = (rawData as unknown) as Config;
// Add comment explaining why assertion is safe
const element = document.getElementById('app') as HTMLElement;
// Safe: element exists in index.html
Strings
// Use single quotes for string literals
const name = 'Alice';
// Template literals for interpolation or multiline
const message = `Hello, ${name}!`;
const query = `
SELECT *
FROM users
WHERE id = ?
`;
// Never use backslash line continuations
Disallowed Features
Feature Alternative
var const or let
Array() constructor [] literal
Object() constructor {} literal
any type unknown
namespace modules
require() import
Default exports Named exports
#private fields private modifier
eval() Never use
const enum Regular enum
debugger Remove before commit
with Never use
Prototype modification Never modify

View File

@@ -130,6 +130,11 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
const hasAnyMetadata =
isComicBookMetadataAvailable ||
!isEmpty(comicInfo) ||
!isNil(locg);
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
@@ -147,16 +152,21 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
};
// Create tab configuration
const openReconcilePanel = useCallback(() => {
setSlidingPanelContentId("metadataReconciliation");
setVisible(true);
}, []);
const tabGroup = createTabConfig({
data: data.data,
comicInfo,
isComicBookMetadataAvailable,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId: _id,
userSettings,
issueName,
acquisition,
onReconcileMetadata: openReconcilePanel,
});
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);

View File

@@ -1,15 +1,108 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useMemo } from "react";
import { isEmpty, isNil } from "lodash";
import ComicVineDetails from "../ComicVineDetails";
export const VolumeInformation = (props): ReactElement => {
const { data } = props;
interface ComicVineMetadata {
volumeInformation?: Record<string, unknown>;
name?: string;
number?: string;
resource_type?: string;
id?: number;
}
interface SourcedMetadata {
comicvine?: ComicVineMetadata;
locg?: Record<string, unknown>;
comicInfo?: unknown;
metron?: unknown;
gcd?: unknown;
[key: string]: unknown;
}
interface VolumeInformationData {
sourcedMetadata?: SourcedMetadata;
inferredMetadata?: { issue?: unknown };
updatedAt?: string;
}
interface VolumeInformationProps {
data: VolumeInformationData;
onReconcile?: () => void;
}
const SOURCED_METADATA_KEYS = ["comicvine", "locg", "comicInfo", "metron", "gcd"];
const SOURCE_LABELS: Record<string, string> = {
comicvine: "ComicVine",
locg: "League of Comic Geeks",
comicInfo: "ComicInfo.xml",
metron: "Metron",
gcd: "Grand Comics Database",
inferredMetadata: "Local File",
};
const MetadataSourceChips = ({
sources,
onReconcile,
}: {
sources: string[];
onReconcile: () => void;
}): ReactElement => (
<div className="flex flex-row flex-wrap items-center gap-2 mb-5 p-3 rounded-lg bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<span className="text-xs text-slate-500 dark:text-slate-400 mr-1">
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
{sources.length} metadata sources detected
</span>
{sources.map((source) => (
<span
key={source}
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
>
<i className="icon-[solar--check-circle-outline] w-3 h-3" />
{SOURCE_LABELS[source] ?? source}
</span>
))}
<button
onClick={onReconcile}
className="ml-auto inline-flex items-center gap-1 text-xs font-semibold text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200 transition-colors cursor-pointer"
>
<i className="icon-[solar--refresh-outline] w-4 h-4" />
Reconcile sources
</button>
</div>
);
export const VolumeInformation = (props: VolumeInformationProps): ReactElement => {
const { data, onReconcile } = props;
const presentSources = useMemo(() => {
const sources = SOURCED_METADATA_KEYS.filter((key) => {
const val = (data?.sourcedMetadata ?? {})[key];
if (isNil(val) || isEmpty(val)) return false;
// locg returns an object even when empty; require at least one non-null value
if (key === "locg") return Object.values(val as Record<string, unknown>).some((v) => !isNil(v) && v !== "");
return true;
});
if (!isNil(data?.inferredMetadata?.issue) && !isEmpty(data.inferredMetadata.issue)) {
sources.push("inferredMetadata");
}
return sources;
}, [data?.sourcedMetadata, data?.inferredMetadata]);
return (
<div key={1}>
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
{presentSources.length > 1 && (
<MetadataSourceChips
sources={presentSources}
onReconcile={onReconcile ?? (() => {})}
/>
)}
{presentSources.length === 1 && data.sourcedMetadata?.comicvine?.volumeInformation && (
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
)}
</div>
);
};

View File

@@ -2,7 +2,6 @@ import React, { lazy } from "react";
import { isNil, isEmpty } from "lodash";
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
const ComicInfoXML = lazy(() => import("./Tabs/ComicInfoXML").then(m => ({ default: m.ComicInfoXML })));
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
@@ -18,26 +17,26 @@ interface TabConfig {
interface TabConfigParams {
data: any;
comicInfo: any;
isComicBookMetadataAvailable: boolean;
hasAnyMetadata: boolean;
areRawFileDetailsAvailable: boolean;
airDCPPQuery: any;
comicObjectId: string;
userSettings: any;
issueName: string;
acquisition?: any;
onReconcileMetadata?: () => void;
}
export const createTabConfig = ({
data,
comicInfo,
isComicBookMetadataAvailable,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId,
userSettings,
issueName,
acquisition,
onReconcileMetadata,
}: TabConfigParams): TabConfig[] => {
return [
{
@@ -46,23 +45,10 @@ export const createTabConfig = ({
icon: (
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data} key={1} />
content: hasAnyMetadata ? (
<VolumeInformation data={data} onReconcile={onReconcileMetadata} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: (
<div key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
),
shouldShow: !isEmpty(comicInfo),
shouldShow: hasAnyMetadata,
},
{
id: 3,