Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions src/components/items/CustomCrosshairUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { useState, useRef } from "react";
import { Button, Form, Alert } from "react-bootstrap";
import { del, get, set } from "idb-keyval";
import { imageToVTF } from "@utils/vtf";

interface CustomCrosshair {
name: string;
vtfData: Uint8Array;
vmtData: string;
previewUrl: string;
width: number;
height: number;
}

const CUSTOM_CROSSHAIRS_KEY = "custom-crosshairs";

export function CustomCrosshairUpload({
onCrosshairAdded,
}: {
onCrosshairAdded?: () => void;
}) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;

setUploading(true);
setError(null);
setSuccess(null);

try {
const file = files[0];

// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("Please upload an image file (PNG, JPG, etc.)");
}

// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
throw new Error("File size must be less than 5MB");
}

// Generate a unique name
const timestamp = Date.now();
const baseName = file.name.replace(/\.[^/.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_");
const customName = `custom_${baseName}_${timestamp}`;

// Convert to VTF
const { vtf, width, height } = await imageToVTF(file, 64);

// Create preview URL from original file
const previewUrl = URL.createObjectURL(file);

// Generate VMT content
const crosshairTargetBase = "vgui/replay/thumbnails/";
const vmtContent = `UnlitGeneric
{
\t$translucent 1
\t$basetexture "${crosshairTargetBase}${customName}"
\t$vertexcolor 1
\t$no_fullbright 1
\t$ignorez 1
}`;

// Create custom crosshair object
const customCrosshair: CustomCrosshair = {
name: customName,
vtfData: vtf,
vmtData: vmtContent,
previewUrl,
width,
height,
};

// Get existing custom crosshairs
const existingCrosshairs: CustomCrosshair[] =
(await get(CUSTOM_CROSSHAIRS_KEY)) || [];

// Add new crosshair
existingCrosshairs.push(customCrosshair);

// Save to IndexedDB
await set(CUSTOM_CROSSHAIRS_KEY, existingCrosshairs);

// Update dynamic crosshair packs
if (globalThis.dynamicCrosshairPacks) {
globalThis.dynamicCrosshairPacks[customName] = {
_0_0: {
name: customName,
preview: previewUrl,
hasCustomMaterial: true,
},
};
}

setSuccess(`Custom crosshair "${customName}" uploaded successfully!`);

// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}

// Notify parent component
if (onCrosshairAdded) {
onCrosshairAdded();
}
} catch (err) {
console.error("Error uploading crosshair:", err);
setError(err instanceof Error ? err.message : "Failed to upload crosshair");
} finally {
setUploading(false);
}
};

return (
<div className="custom-crosshair-upload mb-3">
<h5>Upload Custom Crosshair</h5>
<Form.Group>
<Form.Label>
Select an image file (PNG, JPG, etc.) to use as a crosshair
</Form.Label>
<Form.Control
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
disabled={uploading}
className="bg-dark text-light"
/>
<Form.Text className="text-muted">
Images will be automatically resized to 64x64 pixels with transparency
preserved.
</Form.Text>
</Form.Group>

{uploading && (
<Alert variant="info" className="mt-2">
<div className="d-flex align-items-center">
<div
className="spinner-border spinner-border-sm me-2"
role="status"
>
<span className="visually-hidden">Loading...</span>
</div>
Processing image...
</div>
</Alert>
)}

{error && (
<Alert
variant="danger"
className="mt-2"
dismissible
onClose={() => setError(null)}
>
{error}
</Alert>
)}

{success && (
<Alert
variant="success"
className="mt-2"
dismissible
onClose={() => setSuccess(null)}
>
{success}
</Alert>
)}
</div>
);
}

/**
* Load custom crosshairs from IndexedDB
*/
export async function loadCustomCrosshairs(): Promise<CustomCrosshair[]> {
return (await get(CUSTOM_CROSSHAIRS_KEY)) || [];
}

/**
* Delete a custom crosshair
*/
export async function deleteCustomCrosshair(name: string): Promise<void> {
const crosshairs: CustomCrosshair[] =
(await get(CUSTOM_CROSSHAIRS_KEY)) || [];
const filtered = crosshairs.filter((c) => c.name !== name);
await set(CUSTOM_CROSSHAIRS_KEY, filtered);

// Remove from dynamic crosshair packs
if (globalThis.dynamicCrosshairPacks && globalThis.dynamicCrosshairPacks[name]) {
delete globalThis.dynamicCrosshairPacks[name];
}
}

/**
* Get custom crosshair data for download
*/
export async function getCustomCrosshairForDownload(
name: string,
): Promise<{ vtf: Blob; vmt: Blob } | null> {
const crosshairs: CustomCrosshair[] =
(await get(CUSTOM_CROSSHAIRS_KEY)) || [];
const crosshair = crosshairs.find((c) => c.name === name);

if (!crosshair) {
return null;
}

const vtfBlob = new Blob([crosshair.vtfData], {
type: "application/octet-stream",
});
const vmtBlob = new Blob([crosshair.vmtData], { type: "text/plain" });

return { vtf: vtfBlob, vmt: vmtBlob };
}
47 changes: 47 additions & 0 deletions src/components/items/ItemsInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getNonce } from "@utils/nonce";
import useItemStore from "@store/items";

import { ColorPickerWrapper } from "./ColorPickerWrapper";
import { CustomCrosshairUpload, loadCustomCrosshairs } from "./CustomCrosshairUpload";
import ItemsSelector from "./ItemsSelector";

const cspNonce = getNonce();
Expand Down Expand Up @@ -154,6 +155,47 @@ export default function ItemsInner({ playerClass, items, setResetKey = null }) {
const [liveCrosshairScale, setLiveCrosshairScale] = useState<
number | undefined
>(undefined);

const [customCrosshairsKey, setCustomCrosshairsKey] = useState(0);

// Load custom crosshairs on mount
useEffect(() => {
(async () => {
const customCrosshairs = await loadCustomCrosshairs();
for (const crosshair of customCrosshairs) {
if (!globalThis.dynamicCrosshairPacks) {
globalThis.dynamicCrosshairPacks = {};
}
globalThis.dynamicCrosshairPacks[crosshair.name] = {
_0_0: {
name: crosshair.name,
preview: crosshair.previewUrl,
hasCustomMaterial: true,
},
};

// Add to Misc group if not already there
if (!globalThis.dynamicCrosshairPackGroups) {
globalThis.dynamicCrosshairPackGroups = { Misc: [] };
}
if (!globalThis.dynamicCrosshairPackGroups.Misc) {
globalThis.dynamicCrosshairPackGroups.Misc = [];
}
if (!globalThis.dynamicCrosshairPackGroups.Misc.includes(crosshair.name)) {
globalThis.dynamicCrosshairPackGroups.Misc.push(crosshair.name);
}
}
})();
}, [customCrosshairsKey]);

const handleCustomCrosshairAdded = useCallback(() => {
// Force re-render to show new custom crosshair
setCustomCrosshairsKey(prev => prev + 1);
// Also refresh the parent component if needed
if (setResetKey) {
setResetKey((prev) => prev + 1);
}
}, [setResetKey]);

const currentCrosshairColor =
liveCrosshairColor ??
Expand Down Expand Up @@ -238,6 +280,11 @@ export default function ItemsInner({ playerClass, items, setResetKey = null }) {
>
<div className="container g-0 py-4">
<h3>Crosshairs</h3>
{isDefault && (
<CustomCrosshairUpload
onCrosshairAdded={handleCustomCrosshairAdded}
/>
)}
{selectedCrosshairs && (
<ItemsSelector
playerClass={playerClass}
Expand Down
68 changes: 66 additions & 2 deletions src/utils/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ import TSON from "@utils/tson";
import fastClone from "./fastClone.ts";
import { getCrosshairPacks } from "./game.ts";

// Import custom crosshair utilities
let getCustomCrosshairForDownload: ((name: string) => Promise<{ vtf: Blob; vmt: Blob } | null>) | null = null;

// Dynamically import the custom crosshair module to avoid circular dependencies
(async () => {
try {
const module = await import("../components/items/CustomCrosshairUpload");
getCustomCrosshairForDownload = module.getCustomCrosshairForDownload;
} catch (err) {
console.error("Failed to load custom crosshair module:", err);
}
})();

const idbKeyval = {
get,
set,
Expand Down Expand Up @@ -1805,15 +1818,66 @@ export async function app() {
},
};
for (const crosshairFile of Array.from(crosshairsToDownload)) {
// Check if this is a custom crosshair
const isCustom = crosshairFile.startsWith("custom_");

let crosshairResult;
let crosshairMaterialContents;

if (isCustom && getCustomCrosshairForDownload) {
// Handle custom crosshair
try {
const customData = await getCustomCrosshairForDownload(crosshairFile);
if (customData) {
// Custom crosshair VMT is already generated
const vmtFile = newFile(
await customData.vmt.text(),
`${crosshairFile}.vmt`,
materialsDirectory,
);
if (vmtFile) {
downloads.push({
name: `${crosshairFile}.vmt`,
path: `${crosshairTarget}${crosshairFile}.vmt`,
blob: vmtFile,
});
}

// Add VTF file
const vtfFile = newFile(
await customData.vtf.arrayBuffer(),
`${crosshairFile}.vtf`,
materialsDirectory,
);
if (vtfFile) {
downloads.push({
name: `${crosshairFile}.vtf`,
path: `${crosshairTarget}${crosshairFile}.vtf`,
blob: vtfFile,
});
}
continue;
}
} catch (err) {
console.error(`Failed to get custom crosshair ${crosshairFile}:`, err);
if (!hasAlerted) {
hasAlerted = true;
alert(`Failed to download custom crosshair ${crosshairFile}. Please try again later.`);
}
continue;
}
}

// Handle regular crosshair
const src = `${crosshairSrcBase}${crosshairFile}.vtf`;
const crosshairResult = await writeRemoteFile(src, materialsDirectory);
crosshairResult = await writeRemoteFile(src, materialsDirectory);
if (crosshairResult) {
// now generate the vmt
const crosshairMaterial = fastClone(crosshairMaterialTemplate);
// crosshair file name
crosshairMaterial.UnlitGeneric["$basetexture"] += crosshairFile;
// get the VDF formatted contents
const crosshairMaterialContents = stringify(crosshairMaterial, {
crosshairMaterialContents = stringify(crosshairMaterial, {
pretty: true,
});
// write to the vmt file
Expand Down
Loading