Compare commits

3 Commits
v1 ... main

Author SHA1 Message Date
Harun CAN
5a9b47c9f2 main
All checks were successful
HarunCAN Studio FE Deploy 🎨 / build-and-deploy (push) Successful in 21s
2026-03-23 01:19:37 +03:00
89d3f93207 main
All checks were successful
HarunCAN Studio FE Deploy 🎨 / build-and-deploy (push) Successful in 14s
2026-03-22 22:34:03 +03:00
Harun CAN
f84f60198d Update nginx.conf
All checks were successful
HarunCAN Studio FE Deploy 🎨 / build-and-deploy (push) Successful in 16s
2026-03-19 19:32:50 +03:00
15 changed files with 236 additions and 2387 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -26,5 +26,6 @@ jobs:
docker run -d \
--name haruncan-studio-fe-container \
--restart always \
--network gitea-server_gitea \
-p 1509:80 \
haruncan-studio-fe:latest

View File

@@ -1,4 +0,0 @@
node_modules/
dist/
*.tgz
.env

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "rpi-deploy-mcp-server",
"version": "1.0.0",
"description": "MCP server for Raspberry Pi deployment management via SSH",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"ssh2": "^1.16.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/ssh2": "^1.15.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -1,43 +0,0 @@
// SSH connection and command execution constants
export const SSH_CONFIG = {
host: process.env.RPI_HOST || '95.70.252.214',
port: parseInt(process.env.RPI_PORT || '2222'),
username: process.env.RPI_USER || 'haruncan',
password: process.env.RPI_PASSWORD,
privateKeyPath: process.env.RPI_KEY_PATH,
};
// Safety limits
export const MAX_LOG_LINES = 500;
export const DEFAULT_LOG_LINES = 50;
export const COMMAND_TIMEOUT_MS = 30_000;
export const CHARACTER_LIMIT = 25_000;
// Commands that are considered safe (read-only)
export const SAFE_COMMAND_PREFIXES = [
'docker ps',
'docker logs',
'docker inspect',
'docker network',
'docker images',
'docker stats',
'docker compose ps',
'docker compose logs',
'df',
'free',
'uptime',
'top -bn1',
'cat',
'ls',
'tail',
'head',
'grep',
'wc',
'hostname',
'uname',
'date',
'whoami',
'systemctl status',
'journalctl',
];

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerContainerTools } from './tools/containers.js';
import { registerSystemTools } from './tools/system.js';
import { registerDeployTools } from './tools/deploy.js';
const server = new McpServer({
name: 'rpi-deploy',
version: '1.0.0',
});
// Register all tools
registerContainerTools(server);
registerSystemTools(server);
registerDeployTools(server);
// Start server with stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[rpi-deploy] MCP server started on stdio');
}
main().catch((error) => {
console.error('[rpi-deploy] Fatal error:', error);
process.exit(1);
});

View File

@@ -1,86 +0,0 @@
import { Client, type ConnectConfig } from 'ssh2';
import { readFileSync } from 'fs';
import { SSH_CONFIG, COMMAND_TIMEOUT_MS } from './constants.js';
export interface CommandResult {
stdout: string;
stderr: string;
code: number;
}
function buildConnectConfig(): ConnectConfig {
const config: ConnectConfig = {
host: SSH_CONFIG.host,
port: SSH_CONFIG.port,
username: SSH_CONFIG.username,
readyTimeout: 10_000,
};
if (SSH_CONFIG.privateKeyPath) {
try {
config.privateKey = readFileSync(SSH_CONFIG.privateKeyPath);
} catch {
// fallback to password
}
}
if (!config.privateKey && SSH_CONFIG.password) {
config.password = SSH_CONFIG.password;
}
return config;
}
export async function executeCommand(command: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeout = setTimeout(() => {
conn.end();
reject(new Error(`Command timed out after ${COMMAND_TIMEOUT_MS}ms: ${command}`));
}, COMMAND_TIMEOUT_MS);
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
stream.on('close', (code: number) => {
clearTimeout(timeout);
conn.end();
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? 0 });
});
});
});
conn.on('error', (err) => {
clearTimeout(timeout);
reject(new Error(`SSH connection failed: ${err.message}`));
});
conn.connect(buildConnectConfig());
});
}
export async function testConnection(): Promise<boolean> {
try {
const result = await executeCommand('echo "connected"');
return result.stdout === 'connected';
} catch {
return false;
}
}

View File

@@ -1,149 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
import { DEFAULT_LOG_LINES, MAX_LOG_LINES, CHARACTER_LIMIT } from '../constants.js';
export function registerContainerTools(server: McpServer): void {
// ── rpi_list_containers ──────────────────────────────────────────────
server.registerTool(
'rpi_list_containers',
{
title: 'List Docker Containers',
description: `List all Docker containers on the Raspberry Pi with their status, ports, and resource usage.
Returns a formatted table of all containers (running and stopped) including:
- Container ID, Name, Image, Status, Ports, Created time
Args:
- all (boolean): Include stopped containers (default: true)
- format ('table' | 'json'): Output format (default: 'table')`,
inputSchema: {
all: z.boolean().default(true).describe('Include stopped containers'),
format: z.enum(['table', 'json']).default('table').describe('Output format'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ all, format }) => {
try {
const flags = all ? '-a' : '';
const formatFlag = format === 'json'
? '--format "{{json .}}"'
: '--format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"';
const result = await executeCommand(`docker ps ${flags} ${formatFlag}`);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error: ${result.stderr || 'Failed to list containers'}` }] };
}
const output = result.stdout || 'No containers found.';
return { content: [{ type: 'text', text: output }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_container_logs ───────────────────────────────────────────────
server.registerTool(
'rpi_container_logs',
{
title: 'Get Container Logs',
description: `Fetch logs from a specific Docker container on the Raspberry Pi.
Args:
- container (string): Container name or ID
- lines (number): Number of tail lines to fetch (default: 50, max: 500)
- since (string): Show logs since timestamp (e.g., "1h", "30m", "2024-01-01")
- grep (string): Optional grep filter to apply on logs`,
inputSchema: {
container: z.string().min(1).describe('Container name or ID'),
lines: z.number().int().min(1).max(MAX_LOG_LINES).default(DEFAULT_LOG_LINES).describe('Number of tail lines'),
since: z.string().optional().describe('Show logs since (e.g., "1h", "30m")'),
grep: z.string().optional().describe('Filter logs with grep pattern'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ container, lines, since, grep }) => {
try {
let cmd = `docker logs --tail ${lines}`;
if (since) cmd += ` --since ${since}`;
cmd += ` ${container} 2>&1`;
if (grep) cmd += ` | grep -i "${grep}"`;
const result = await executeCommand(cmd);
if (result.code !== 0 && !result.stdout) {
return { content: [{ type: 'text', text: `Error: ${result.stderr || `Container '${container}' not found`}` }] };
}
let output = result.stdout || 'No logs found.';
if (output.length > CHARACTER_LIMIT) {
output = output.slice(-CHARACTER_LIMIT);
output = `[...truncated, showing last ${CHARACTER_LIMIT} characters]\n\n${output}`;
}
return { content: [{ type: 'text', text: `## Logs: ${container} (last ${lines} lines)\n\n\`\`\`\n${output}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_restart_container ────────────────────────────────────────────
server.registerTool(
'rpi_restart_container',
{
title: 'Restart Docker Container',
description: `Restart a specific Docker container on the Raspberry Pi.
⚠️ This is a destructive operation — the container will be temporarily unavailable.
Args:
- container (string): Container name or ID to restart
- timeout (number): Seconds to wait before killing (default: 10)`,
inputSchema: {
container: z.string().min(1).describe('Container name or ID'),
timeout: z.number().int().min(0).max(120).default(10).describe('Seconds to wait before killing'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ container, timeout }) => {
try {
const result = await executeCommand(`docker restart --time ${timeout} ${container}`);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error restarting '${container}': ${result.stderr}` }] };
}
// Verify the container is running
const verify = await executeCommand(`docker ps --filter "name=${container}" --format "{{.Names}}\t{{.Status}}"`);
return {
content: [{
type: 'text',
text: `✅ Container '${container}' restarted successfully.\n\nCurrent status:\n${verify.stdout}`
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -1,163 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
export function registerDeployTools(server: McpServer): void {
// ── rpi_deploy_status ────────────────────────────────────────────────
server.registerTool(
'rpi_deploy_status',
{
title: 'Deploy Status',
description: `Check the current deployment status on the Raspberry Pi.
Gathers information about:
- Running containers and their health
- Docker Compose project status
- Last container restart/creation times
- Gitea runner status (if available)`,
inputSchema: {
compose_dir: z.string().optional().describe('Path to docker-compose directory (default: auto-detect)'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ compose_dir }) => {
try {
const sections: string[] = ['# 🚀 Deploy Status\n'];
// Container status with creation time
const containers = await executeCommand(
'docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.CreatedAt}}"'
);
sections.push(`## Containers\n\`\`\`\n${containers.stdout || 'No containers found'}\n\`\`\`\n`);
// Docker compose status if compose_dir specified
if (compose_dir) {
const composeStatus = await executeCommand(`cd ${compose_dir} && docker compose ps 2>&1`);
sections.push(`## Docker Compose (${compose_dir})\n\`\`\`\n${composeStatus.stdout || composeStatus.stderr}\n\`\`\`\n`);
}
// Check for Gitea runner
const runner = await executeCommand('docker ps --filter "name=runner" --filter "name=gitea" --format "{{.Names}}\t{{.Status}}"');
if (runner.stdout) {
sections.push(`## Gitea Runner\n\`\`\`\n${runner.stdout}\n\`\`\`\n`);
}
// Recent container events (last 10)
const events = await executeCommand(
'docker events --since "1h" --until "0s" --format "{{.Time}} {{.Action}} {{.Actor.Attributes.name}}" 2>/dev/null | tail -10'
);
if (events.stdout) {
sections.push(`## Recent Events (last 1h)\n\`\`\`\n${events.stdout}\n\`\`\`\n`);
}
return { content: [{ type: 'text', text: sections.join('\n') }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_compose_up ────────────────────────────────────────────
server.registerTool(
'rpi_docker_compose_up',
{
title: 'Docker Compose Up',
description: `Run 'docker compose up -d' in a specified directory on the Raspberry Pi.
⚠️ This will start/restart services defined in docker-compose.yml.
Args:
- directory (string): Absolute path to the directory containing docker-compose.yml
- build (boolean): Force rebuild images before starting (default: false)
- services (string[]): Specific services to start (default: all)`,
inputSchema: {
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
build: z.boolean().default(false).describe('Rebuild images before starting'),
services: z.array(z.string()).optional().describe('Specific services to start'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ directory, build, services }) => {
try {
let cmd = `cd ${directory} && docker compose up -d`;
if (build) cmd += ' --build';
if (services?.length) cmd += ` ${services.join(' ')}`;
const result = await executeCommand(cmd);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `❌ docker compose up failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
}
// Show final status
const status = await executeCommand(`cd ${directory} && docker compose ps`);
return {
content: [{
type: 'text',
text: `✅ docker compose up completed in \`${directory}\`\n\n## Current Status\n\`\`\`\n${status.stdout}\n\`\`\``
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_compose_down ──────────────────────────────────────────
server.registerTool(
'rpi_docker_compose_down',
{
title: 'Docker Compose Down',
description: `Run 'docker compose down' in a specified directory on the Raspberry Pi.
⚠️ This will stop and remove containers, networks defined in docker-compose.yml.
Args:
- directory (string): Absolute path to the directory containing docker-compose.yml
- volumes (boolean): Remove named volumes too (default: false)`,
inputSchema: {
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
volumes: z.boolean().default(false).describe('Also remove named volumes'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ directory, volumes }) => {
try {
let cmd = `cd ${directory} && docker compose down`;
if (volumes) cmd += ' -v';
const result = await executeCommand(cmd);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `❌ docker compose down failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
}
return {
content: [{
type: 'text',
text: `✅ docker compose down completed in \`${directory}\`\n\n\`\`\`\n${result.stdout || result.stderr || 'Services stopped and removed.'}\n\`\`\``
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -1,134 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
import { SAFE_COMMAND_PREFIXES } from '../constants.js';
export function registerSystemTools(server: McpServer): void {
// ── rpi_system_status ────────────────────────────────────────────────
server.registerTool(
'rpi_system_status',
{
title: 'Raspberry Pi System Status',
description: `Get system resource usage on the Raspberry Pi: CPU load, memory, disk, uptime, and temperature.
Returns a formatted overview of the system health.`,
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
try {
const commands = [
{ label: 'Hostname', cmd: 'hostname' },
{ label: 'Uptime', cmd: 'uptime' },
{ label: 'CPU Temperature', cmd: 'cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo "N/A"' },
{ label: 'Memory', cmd: 'free -h | head -3' },
{ label: 'Disk', cmd: 'df -h / | tail -1' },
{ label: 'CPU Load', cmd: 'top -bn1 | head -5' },
];
const sections: string[] = ['# 🖥️ Raspberry Pi System Status\n'];
for (const { label, cmd } of commands) {
const result = await executeCommand(cmd);
let value = result.stdout || 'N/A';
// Convert CPU temp from millidegrees
if (label === 'CPU Temperature' && value !== 'N/A') {
const temp = parseInt(value);
if (!isNaN(temp)) {
value = `${(temp / 1000).toFixed(1)}°C`;
}
}
sections.push(`## ${label}\n\`\`\`\n${value}\n\`\`\`\n`);
}
return { content: [{ type: 'text', text: sections.join('\n') }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_networks ──────────────────────────────────────────────
server.registerTool(
'rpi_docker_networks',
{
title: 'List Docker Networks',
description: `List all Docker networks on the Raspberry Pi with driver and scope info.`,
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
try {
const result = await executeCommand('docker network ls --format "table {{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}"');
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }] };
}
return { content: [{ type: 'text', text: `## Docker Networks\n\n\`\`\`\n${result.stdout}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_run_command ──────────────────────────────────────────────────
server.registerTool(
'rpi_run_command',
{
title: 'Run Safe Command on RPi',
description: `Execute a read-only command on the Raspberry Pi via SSH.
Only whitelisted safe commands are allowed (docker ps, docker logs, df, free, uptime, cat, ls, tail, head, grep, etc.).
Args:
- command (string): The command to execute (must start with a safe prefix)`,
inputSchema: {
command: z.string().min(1).max(500).describe('Safe read-only command to execute'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ command }) => {
try {
const trimmedCmd = command.trim();
const isSafe = SAFE_COMMAND_PREFIXES.some(prefix => trimmedCmd.startsWith(prefix));
if (!isSafe) {
return {
content: [{
type: 'text',
text: `❌ Command rejected: '${trimmedCmd}' is not in the safe command list.\n\nAllowed prefixes:\n${SAFE_COMMAND_PREFIXES.map(p => ` - ${p}`).join('\n')}`
}]
};
}
const result = await executeCommand(trimmedCmd);
const output = result.stdout || result.stderr || '(no output)';
const status = result.code === 0 ? '✅' : `⚠️ (exit code: ${result.code})`;
return { content: [{ type: 'text', text: `${status} \`${trimmedCmd}\`\n\n\`\`\`\n${output}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -13,6 +13,30 @@ server {
try_files $uri $uri/ /index.html;
}
# API Proxy — resolver sayesinde backend kapalıyken de nginx başlar
location /api {
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend "backend-container:3000";
proxy_pass http://$backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Uploads Proxy
location /uploads {
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend "backend-container:3000";
proxy_pass http://$backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Statik dosya cache (JS, CSS, images, fonts)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;

View File

@@ -32,8 +32,15 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
// Client state
const [newClientName, setNewClientName] = useState('');
const [newClientWebsite, setNewClientWebsite] = useState('');
const [clientUploading, setClientUploading] = useState(false);
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const [editingClientName, setEditingClientName] = useState('');
const [editingClientWebsite, setEditingClientWebsite] = useState('');
const [clientSaving, setClientSaving] = useState(false);
const [clientLogoUploading, setClientLogoUploading] = useState<string | null>(null);
const clientFileRef = useRef<HTMLInputElement>(null);
const clientLogoChangeRef = useRef<HTMLInputElement>(null);
// Audit Logs state
const [auditLogs, setAuditLogs] = useState<api.AuditLogAPI[]>([]);
@@ -168,9 +175,10 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
throw new Error('Sunucu görsel URL\'sini döndüremedi. Lütfen tekrar deneyin.');
}
await api.createClient({ name: newClientName.trim(), logo: logoUrl });
await api.createClient({ name: newClientName.trim(), logo: logoUrl, website: newClientWebsite.trim() || undefined });
await refreshClients();
setNewClientName('');
setNewClientWebsite('');
showMessage('Marka eklendi', 'success');
} catch (err: any) {
showMessage(err.message || 'Marka eklenemedi', 'error');
@@ -516,8 +524,8 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
<div className="p-4 bg-slate-900/50 border border-white/10 rounded-lg mb-6">
<h4 className="text-sm font-mono text-slate-400 mb-3">YENİ MARKA EKLE</h4>
<div className="flex items-end gap-4">
<div className="flex-1 space-y-2">
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500">MARKA ADI</label>
<input
type="text"
@@ -527,48 +535,204 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
placeholder="Netflix, Disney, Warner Bros..."
/>
</div>
<div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500">WEB SİTESİ <span className="text-slate-600">(Opsiyonel SEO backlink)</span></label>
<input
ref={clientFileRef}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) await handleAddClient(file);
e.target.value = '';
}}
type="url"
value={newClientWebsite}
onChange={(e) => setNewClientWebsite(e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-4 py-2.5 text-white focus:border-[#FF5733] outline-none transition-colors text-sm rounded-sm"
placeholder="https://www.netflix.com"
/>
<button
onClick={() => clientFileRef.current?.click()}
disabled={clientUploading || !newClientName.trim()}
className="flex items-center gap-2 px-4 py-2.5 bg-[#FF5733] text-white font-bold text-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-sm"
>
<Upload className="w-4 h-4" />
{clientUploading ? 'Yükleniyor...' : 'Logo Yükle & Ekle'}
</button>
</div>
</div>
<div className="flex items-center gap-4">
<input
ref={clientFileRef}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) await handleAddClient(file);
e.target.value = '';
}}
/>
<button
onClick={() => clientFileRef.current?.click()}
disabled={clientUploading || !newClientName.trim()}
className="flex items-center gap-2 px-4 py-2.5 bg-[#FF5733] text-white font-bold text-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-sm"
>
<Upload className="w-4 h-4" />
{clientUploading ? 'Yükleniyor...' : 'Logo Yükle & Ekle'}
</button>
{newClientWebsite && (
<span className="text-[10px] font-mono text-green-400/70 flex items-center gap-1">🔗 SEO backlink aktif</span>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data.clients.map((client) => (
<div key={client.id} className="relative group p-4 bg-slate-900/50 border border-white/10 rounded-lg flex flex-col items-center">
<img
src={api.getMediaUrl(client.logo)}
alt={client.name}
className="h-[60px] w-auto object-contain mb-3"
style={{ maxWidth: '200px' }}
/>
<span className="text-xs font-mono text-slate-400 text-center">{client.name}</span>
<button
onClick={() => handleRemoveClient(client.id)}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{/* Hidden file input for logo change */}
<input
ref={clientLogoChangeRef}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file && editingClientId) {
setClientLogoUploading(editingClientId);
try {
const response = await api.uploadFile(file);
const media = (response as any).data || response;
let logoUrl = '';
if (media && typeof media === 'object') {
if (media.url) logoUrl = media.url;
else if (media.filename) logoUrl = `/uploads/${media.filename}`;
}
if (!logoUrl) throw new Error('Görsel URL alınamadı');
await api.updateClient(editingClientId, { logo: logoUrl });
await refreshClients();
showMessage('Logo güncellendi', 'success');
} catch (err: any) {
showMessage(err.message || 'Logo güncellenemedi', 'error');
} finally {
setClientLogoUploading(null);
}
}
e.target.value = '';
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.clients.map((client) => {
const isEditing = editingClientId === client.id;
return (
<div key={client.id} className={`relative group p-4 border rounded-lg flex flex-col transition-colors ${
isEditing
? 'bg-slate-800/80 border-[#FF5733]/30'
: 'bg-slate-900/50 border-white/10'
}`}>
{/* Logo area */}
<div className="flex items-center justify-center mb-3 relative">
<img
src={api.getMediaUrl(client.logo)}
alt={client.name}
className={`h-[60px] w-auto object-contain transition-opacity ${
clientLogoUploading === client.id ? 'opacity-30' : ''
}`}
style={{ maxWidth: '200px' }}
/>
{clientLogoUploading === client.id && (
<span className="absolute text-xs text-white animate-pulse">Yükleniyor...</span>
)}
{isEditing && clientLogoUploading !== client.id && (
<button
onClick={() => clientLogoChangeRef.current?.click()}
className="absolute inset-0 bg-black/50 rounded flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity cursor-pointer"
title="Logo değiştir"
>
<Upload className="w-5 h-5 text-white" />
</button>
)}
</div>
{/* Content area */}
{isEditing ? (
<div className="w-full space-y-3 mt-1">
<div className="space-y-1">
<label className="text-[10px] font-mono text-slate-500">MARKA ADI</label>
<input
type="text"
value={editingClientName}
onChange={(e) => setEditingClientName(e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-3 py-1.5 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-mono text-slate-500">WEB SİTESİ <span className="text-slate-600">(SEO)</span></label>
<input
type="url"
value={editingClientWebsite}
onChange={(e) => setEditingClientWebsite(e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-3 py-1.5 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
placeholder="https://www.example.com"
/>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={async () => {
setClientSaving(true);
try {
await api.updateClient(client.id, {
name: editingClientName.trim() || client.name,
website: editingClientWebsite.trim() || null as any,
});
await refreshClients();
setEditingClientId(null);
showMessage('Marka güncellendi', 'success');
} catch (err: any) {
showMessage(err.message || 'Güncellenemedi', 'error');
} finally {
setClientSaving(false);
}
}}
disabled={clientSaving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-bold bg-[#FF5733] text-white rounded-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50"
>
<Save className="w-3 h-3" />
{clientSaving ? 'Kaydediliyor...' : 'Kaydet'}
</button>
<button
onClick={() => setEditingClientId(null)}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white border border-white/10 rounded-sm transition-colors"
>
İptal
</button>
</div>
</div>
) : (
<>
<span className="text-xs font-mono text-slate-400 text-center mb-1">{client.name}</span>
<span
className="text-[10px] font-mono px-2 py-0.5 rounded border transition-colors text-center"
style={{
borderColor: client.website ? 'rgba(74, 222, 128, 0.3)' : 'rgba(255,255,255,0.05)',
color: client.website ? 'rgb(74, 222, 128)' : 'rgb(71, 85, 105)',
backgroundColor: client.website ? 'rgba(74, 222, 128, 0.05)' : 'transparent',
}}
>
{client.website ? `🔗 ${client.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}` : 'Web sitesi yok'}
</span>
</>
)}
{/* Action buttons (visible on hover when not editing) */}
{!isEditing && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditingClientId(client.id);
setEditingClientName(client.name);
setEditingClientWebsite(client.website || '');
}}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded transition-colors"
title="Düzenle"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</button>
<button
onClick={() => handleRemoveClient(client.id)}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
title="Sil"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
</section>
)}

View File

@@ -30,16 +30,18 @@ export default function Clients() {
{duplicated.map((client, index) => (
<a
key={`${client.id}-${index}`}
href={client.website || '#'}
href={client.website || undefined}
target={client.website ? '_blank' : undefined}
rel="noopener noreferrer"
className="flex-shrink-0 group transition-all duration-500"
title={client.name}
rel={client.website ? 'noopener' : undefined}
className={`flex-shrink-0 group transition-all duration-500 ${client.website ? 'cursor-pointer' : 'cursor-default'}`}
title={client.website ? `${client.name} — Visit Website` : client.name}
aria-label={client.website ? `Visit ${client.name} official website` : `${client.name} logo`}
onClick={(e) => { if (!client.website) e.preventDefault(); }}
>
<img
src={getMediaUrl(client.logo)}
alt={client.name}
className="h-[50px] md:h-[60px] w-auto object-contain opacity-95 group-hover:opacity-100 transition-all duration-500"
alt={`${client.name} logo — official partner of Harun CAN Studio`}
className={`h-[50px] md:h-[60px] w-auto object-contain opacity-95 group-hover:opacity-100 transition-all duration-500 ${client.website ? 'group-hover:scale-105' : ''}`}
style={{ maxWidth: '250px' }}
/>
</a>