🔨 Clearning up the VolumeInformation tab
This commit is contained in:
353
.claude/skills/typescript/SKILL.md
Normal file
353
.claude/skills/typescript/SKILL.md
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user