Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ModelsProvider } from "./contexts/ModelContext";
import { MainContent } from "./components/MainContent";
import { StringsProvider } from "./contexts/strings";
import { useStrings } from "./contexts/strings";
import { TOAST_CONFIG } from "./constants";

function AppContent() {
const { localizableStrings, error, importFile, exportFile } = useStrings();
Expand All @@ -35,7 +36,7 @@ function App() {
return (
<ModelsProvider>
<StringsProvider>
<ToastContainer position="top-right" autoClose={5000} />
<ToastContainer {...TOAST_CONFIG} />
<AppContent />
</StringsProvider>
</ModelsProvider>
Expand Down
42 changes: 6 additions & 36 deletions src/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useModels, useModelSelector } from "../contexts/model/hooks";
import ReactMarkdown from "react-markdown";
import { extractProviderFromModelId, capitalizeProvider } from "../utils/modelUtils";
import { formatModelPricing } from "../utils/pricingUtils";

export function ModelSelector() {
const { selectedModel, setSelectedModel, isLoading, error } = useModels();
Expand All @@ -16,10 +18,6 @@ export function ModelSelector() {
providers,
} = useModelSelector();

// Extract provider from model ID (e.g., "anthropic/claude-3" -> "anthropic")
const getProvider = (modelId: string): string => {
return modelId.split("/")[0];
};

if (isLoading) {
return (
Expand Down Expand Up @@ -60,7 +58,7 @@ export function ModelSelector() {
>
{providers.map((provider) => (
<option key={provider} value={provider}>
{provider.charAt(0).toUpperCase() + provider.slice(1)}
{capitalizeProvider(provider)}
</option>
))}
</select>
Expand Down Expand Up @@ -93,22 +91,8 @@ export function ModelSelector() {
className="block w-full rounded-md border border-gray-300 py-2 pl-3 pr-10 text-base focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
>
{filteredModels.map((model) => {
const provider = getProvider(model.id);
const pricing = (() => {
if (model.pricing.prompt === "0" && model.pricing.completion === "0" && model.pricing.image === "0") {
return "Free";
}
const parts = [];
const promptPrice = parseFloat(model.pricing.prompt) * 1000000;
const completionPrice = parseFloat(model.pricing.completion) * 1000000;
const imagePrice = parseFloat(model.pricing.image) * 1000; // Per thousand for images

if (promptPrice > 0) parts.push(`Input: $${promptPrice.toFixed(2)}/M`);
if (completionPrice > 0 && completionPrice !== promptPrice) parts.push(`Output: $${completionPrice.toFixed(2)}/M`);
if (imagePrice > 0) parts.push(`Image: $${imagePrice.toFixed(2)}/K`);

return parts.join(" | ");
})();
const provider = extractProviderFromModelId(model.id);
const pricing = formatModelPricing(model.pricing);
return (
<option key={model.id} value={model.id}>
{`${provider.toUpperCase()}: ${model.name} (${pricing})`}
Expand All @@ -135,21 +119,7 @@ export function ModelSelector() {
Context Length: {model.context_length.toLocaleString()} tokens
</div>
<div className="text-xs text-gray-600">
Pricing: {(() => {
if (model.pricing.prompt === "0" && model.pricing.completion === "0" && model.pricing.image === "0") {
return "Free";
}
const parts = [];
const promptPrice = parseFloat(model.pricing.prompt) * 1000000;
const completionPrice = parseFloat(model.pricing.completion) * 1000000;
const imagePrice = parseFloat(model.pricing.image) * 1000; // Per thousand for images

if (promptPrice > 0) parts.push(`Input: $${promptPrice.toFixed(2)}/M tokens`);
if (completionPrice > 0 && completionPrice !== promptPrice) parts.push(`Output: $${completionPrice.toFixed(2)}/M tokens`);
if (imagePrice > 0) parts.push(`Image: $${imagePrice.toFixed(2)}/K`);

return parts.join(" | ");
})()}
Pricing: {formatModelPricing(model.pricing, true)}
</div>
</>
);
Expand Down
9 changes: 3 additions & 6 deletions src/components/TranslationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toast } from "react-toastify";
import { getStoredApiKey } from "../utils/apiKeyUtils";
import { LoadingSpinner } from "./LoadingSpinner";
import { useAITranslation } from "../hooks/useAITranslation";
import { TEXTAREA_CONFIG } from "../constants";

interface TranslationEditorProps {
value: string | undefined;
Expand Down Expand Up @@ -35,20 +36,16 @@ export const TranslationEditor = memo(
const textareaRef = useRef<HTMLTextAreaElement>(null);

const adjustTextareaHeight = (element: HTMLTextAreaElement, initialLoad = false) => {
const lineHeight = 20; // Approximate line height in pixels
const paddingHeight = 8; // Total vertical padding (4px top + 4px bottom)
const charsPerLine = 60; // Number of characters per line

// Reset height to auto to get proper scrollHeight
element.style.height = "auto";

// Calculate number of lines based on character count
const textLength = element.value.length;
const lines = Math.ceil(textLength / charsPerLine);
const lines = Math.ceil(textLength / TEXTAREA_CONFIG.CHARS_PER_LINE);
const minLines = initialLoad ? Math.max(lines + 1, 1) : Math.max(lines, 1);

// Set minimum height based on number of lines
const minHeight = minLines * lineHeight + paddingHeight;
const minHeight = minLines * TEXTAREA_CONFIG.LINE_HEIGHT + TEXTAREA_CONFIG.PADDING_HEIGHT;

// Set height to either minHeight or scrollHeight, whichever is larger
element.style.height = `${Math.max(minHeight, element.scrollHeight)}px`;
Expand Down
22 changes: 22 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Environment configuration for the application
*
* This centralizes all environment-dependent configuration,
* making it easy to switch between environments and mock for testing.
*/

export const ENV = {
/**
* OpenRouter API base URL
* Can be overridden via VITE_OPENROUTER_API_URL environment variable
*/
OPENROUTER_API_URL: import.meta.env.VITE_OPENROUTER_API_URL || 'https://openrouter.ai/api/v1',
} as const;

/**
* API endpoints derived from environment configuration
*/
export const API_ENDPOINTS = {
CHAT_COMPLETIONS: `${ENV.OPENROUTER_API_URL}/chat/completions`,
MODELS: `${ENV.OPENROUTER_API_URL}/models`,
} as const;
23 changes: 23 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,26 @@ export const ERROR_MESSAGES = {
exportFailed: "Failed to export translations",
noFileLoaded: "No file is currently loaded",
} as const;

export const TEXTAREA_CONFIG = {
LINE_HEIGHT: 20,
PADDING_HEIGHT: 8,
CHARS_PER_LINE: 60,
} as const;

export const EXPORT_FORMAT = {
JSON_INDENT: 2,
KEY_VALUE_SPACING_REGEX: /"([^"]+)":/g,
KEY_VALUE_SPACING_REPLACEMENT: '"$1" :',
} as const;

export const PRICING_MULTIPLIERS = {
TOKENS_PER_MILLION: 1000000,
IMAGES_PER_THOUSAND: 1000,
} as const;

export const TOAST_CONFIG = {
position: "top-right" as const,
autoClose: 5000,
hideProgressBar: false,
} as const;
15 changes: 10 additions & 5 deletions src/utils/FileManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { LocalizableStrings, VariationsMap, VariationValue } from "../types";
import { EXPORT_FORMAT } from "../constants";
import { parsePathPart } from "./pathUtils";

export class FileManager {
private currentFile: LocalizableStrings | null = null;
Expand Down Expand Up @@ -29,9 +31,12 @@ export class FileManager {
* Exports the current data as a file named "Localizable.xcstrings".
*/
async exportFile(data: LocalizableStrings): Promise<void> {
const jsonString = JSON.stringify(data, null, 2);
const jsonString = JSON.stringify(data, null, EXPORT_FORMAT.JSON_INDENT);
// Optionally tweak formatting if desired:
const formattedJson = jsonString.replace(/"([^"]+)":/g, '"$1" :');
const formattedJson = jsonString.replace(
EXPORT_FORMAT.KEY_VALUE_SPACING_REGEX,
EXPORT_FORMAT.KEY_VALUE_SPACING_REPLACEMENT
);

const blob = new Blob([formattedJson], { type: "application/json" });
const url = URL.createObjectURL(blob);
Expand Down Expand Up @@ -125,7 +130,7 @@ export class FileManager {
let current = variations;

pathParts.forEach((part, index) => {
const [variationType, variationKey] = part.split(":");
const { variationType, variationKey } = parsePathPart(part);
if (!current[variationType]) {
current[variationType] = {};
}
Expand Down Expand Up @@ -165,7 +170,7 @@ export class FileManager {
let current = variations;

for (let i = 0; i < pathParts.length; i++) {
const [variationType, variationKey] = pathParts[i].split(":");
const { variationType, variationKey } = parsePathPart(pathParts[i]);

// Final part => remove the node
if (i === pathParts.length - 1) {
Expand Down Expand Up @@ -201,7 +206,7 @@ export class FileManager {

let current = variations;
for (const part of pathParts) {
const [variationType, variationKey] = part.split(":");
const { variationType, variationKey } = parsePathPart(part);
stack.push({ variations: current, variationType, variationKey });

const deeperVariations = current[variationType]?.[variationKey]?.variations;
Expand Down
3 changes: 2 additions & 1 deletion src/utils/modelService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getStoredApiKey } from "./apiKeyUtils";
import { API_ENDPOINTS } from "../config/env";

interface ModelPricing {
prompt: string;
Expand Down Expand Up @@ -31,7 +32,7 @@ export interface Model {
}

export const fetchModels = async (): Promise<Model[]> => {
const response = await fetch("https://openrouter.ai/api/v1/models", {
const response = await fetch(API_ENDPOINTS.MODELS, {
headers: {
Authorization: `Bearer ${getStoredApiKey()}`,
},
Expand Down
28 changes: 28 additions & 0 deletions src/utils/modelUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Utilities for working with AI model IDs and provider names
*/

/**
* Extracts the provider name from a model ID
* @example extractProviderFromModelId("anthropic/claude-3") => "anthropic"
*/
export const extractProviderFromModelId = (modelId: string): string => {
return modelId.split('/')[0];
};

/**
* Capitalizes the first letter of a provider name
* @example capitalizeProvider("anthropic") => "Anthropic"
*/
export const capitalizeProvider = (provider: string): string => {
return provider.charAt(0).toUpperCase() + provider.slice(1);
};

/**
* Formats a provider name from a model ID
* @example formatProviderName("anthropic/claude-3") => "Anthropic"
*/
export const formatProviderName = (modelId: string): string => {
const provider = extractProviderFromModelId(modelId);
return capitalizeProvider(provider);
};
25 changes: 25 additions & 0 deletions src/utils/pathUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Utilities for parsing and manipulating variation paths
*/

export interface ParsedPathPart {
variationType: string;
variationKey: string;
}

/**
* Parses a single path part in the format "type:key"
* @example parsePathPart("plural:one") => { variationType: "plural", variationKey: "one" }
*/
export const parsePathPart = (part: string): ParsedPathPart => {
const [variationType, variationKey] = part.split(":");
return { variationType, variationKey };
};

/**
* Parses a full path string into an array of parsed path parts
* @example parsePath("plural:one.device:iphone") => [{ variationType: "plural", variationKey: "one" }, ...]
*/
export const parsePath = (path: string): ParsedPathPart[] => {
return path.split(".").map(parsePathPart);
};
44 changes: 44 additions & 0 deletions src/utils/pricingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Utilities for formatting AI model pricing information
*/

import { PRICING_MULTIPLIERS } from "../constants";

export interface ModelPricing {
prompt: string;
completion: string;
image: string;
}

/**
* Formats model pricing for display
* @param pricing - The model pricing object
* @param detailed - Whether to include detailed labels (e.g., "tokens")
* @returns Formatted pricing string
*/
export const formatModelPricing = (pricing: ModelPricing, detailed = false): string => {
if (pricing.prompt === "0" && pricing.completion === "0" && pricing.image === "0") {
return "Free";
}

const parts: string[] = [];
const promptPrice = parseFloat(pricing.prompt) * PRICING_MULTIPLIERS.TOKENS_PER_MILLION;
const completionPrice = parseFloat(pricing.completion) * PRICING_MULTIPLIERS.TOKENS_PER_MILLION;
const imagePrice = parseFloat(pricing.image) * PRICING_MULTIPLIERS.IMAGES_PER_THOUSAND;

if (promptPrice > 0) {
const suffix = detailed ? ' tokens' : '';
parts.push(`Input: $${promptPrice.toFixed(2)}/M${suffix}`);
}

if (completionPrice > 0 && completionPrice !== promptPrice) {
const suffix = detailed ? ' tokens' : '';
parts.push(`Output: $${completionPrice.toFixed(2)}/M${suffix}`);
}

if (imagePrice > 0) {
parts.push(`Image: $${imagePrice.toFixed(2)}/K`);
}

return parts.join(" | ");
};
5 changes: 2 additions & 3 deletions src/utils/translationService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { TranslationRequest } from "../types";
import { getStoredApiKey } from "./apiKeyUtils";

const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
import { API_ENDPOINTS } from "../config/env";

const createTranslationPrompt = ({ sourceLanguage, targetLanguage, translationKey, comment, sourceText }: TranslationRequest): string => {
return `
Expand Down Expand Up @@ -52,7 +51,7 @@ Please proceed with the translation task.`;
};

export const getAITranslation = async (translationRequest: TranslationRequest, model: string): Promise<string> => {
const response = await fetch(OPENROUTER_API_URL, {
const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
method: "POST",
headers: {
Authorization: `Bearer ${getStoredApiKey()}`,
Expand Down
3 changes: 2 additions & 1 deletion src/utils/variationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StringEntry, VariationRow, VariationValue, VariationsMap } from "../types";
import { parsePathPart } from "./pathUtils";

export const getVariationValue = (variations: VariationsMap | undefined, path: string | undefined): string | undefined => {
if (!variations || !path) return undefined;
Expand All @@ -7,7 +8,7 @@ export const getVariationValue = (variations: VariationsMap | undefined, path: s
let current = variations;

for (const part of parts) {
const [type, key] = part.split(":");
const { variationType: type, variationKey: key } = parsePathPart(part);
const variationValue = current[type]?.[key];
if (!variationValue) return undefined;

Expand Down