Compare commits

...

3 commits
1.0.4 ... main

Author SHA1 Message Date
Sedat Öztürk
3ac72ad19b Docker düzenlemesi 2026-04-23 15:30:20 +03:00
Sedat Öztürk
c26f0cb5bc DbMigrator UI 2026-04-23 13:36:51 +03:00
Sedat Öztürk
3db9fc332b Template Button güncellemesi 2026-03-30 23:40:20 +03:00
37 changed files with 807 additions and 1063 deletions

View file

@ -44,6 +44,7 @@ COPY "src/Sozsoft.Platform.EntityFrameworkCore/Sozsoft.Platform.EntityFrameworkC
COPY "src/Sozsoft.Platform.HttpApi/Sozsoft.Platform.HttpApi.csproj" "src/Sozsoft.Platform.HttpApi/" COPY "src/Sozsoft.Platform.HttpApi/Sozsoft.Platform.HttpApi.csproj" "src/Sozsoft.Platform.HttpApi/"
COPY "src/Sozsoft.Platform.HttpApi.Client/Sozsoft.Platform.HttpApi.Client.csproj" "src/Sozsoft.Platform.HttpApi.Client/" COPY "src/Sozsoft.Platform.HttpApi.Client/Sozsoft.Platform.HttpApi.Client.csproj" "src/Sozsoft.Platform.HttpApi.Client/"
COPY "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" "src/Sozsoft.Platform.HttpApi.Host/" COPY "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" "src/Sozsoft.Platform.HttpApi.Host/"
COPY "src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj" "src/Sozsoft.Platform.DbMigrator/"
COPY "test/Sozsoft.Platform.EntityFrameworkCore.Tests/Sozsoft.Platform.EntityFrameworkCore.Tests.csproj" "test/Sozsoft.Platform.EntityFrameworkCore.Tests/" COPY "test/Sozsoft.Platform.EntityFrameworkCore.Tests/Sozsoft.Platform.EntityFrameworkCore.Tests.csproj" "test/Sozsoft.Platform.EntityFrameworkCore.Tests/"
COPY "test/Sozsoft.Platform.TestBase/Sozsoft.Platform.TestBase.csproj" "test/Sozsoft.Platform.TestBase/" COPY "test/Sozsoft.Platform.TestBase/Sozsoft.Platform.TestBase.csproj" "test/Sozsoft.Platform.TestBase/"
RUN dotnet restore "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" RUN dotnet restore "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj"
@ -51,6 +52,7 @@ RUN dotnet restore "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.H
COPY . . COPY . .
RUN mkdir -p publish RUN mkdir -p publish
RUN dotnet publish "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" -c Release -o /app/publish --no-restore RUN dotnet publish "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" -c Release -o /app/publish --no-restore
RUN dotnet publish "src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj" -c Release -o /app/migrator
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
@ -97,4 +99,8 @@ EXPOSE 443
WORKDIR /srv/app WORKDIR /srv/app
COPY --from=build /app/publish . COPY --from=build /app/publish .
# Migrator publish çıktısını Setup modunun çağırabilmesi için kopyala
COPY --from=build /app/migrator /srv/Sozsoft.Platform.DbMigrator
ENTRYPOINT ["./Sozsoft.Platform.HttpApi.Host"] ENTRYPOINT ["./Sozsoft.Platform.HttpApi.Host"]

View file

@ -6144,6 +6144,12 @@
"tr": "Yapay zekanın iş süreçlerindeki etkileri ve geleceği...", "tr": "Yapay zekanın iş süreçlerindeki etkileri ve geleceği...",
"en": "The impacts and future of AI in business processes..." "en": "The impacts and future of AI in business processes..."
}, },
{
"resourceName": "Platform",
"key": "Public.blog.posts.web.excerpt",
"tr": "Modern web geliştirme teknikleri ve araçları...",
"en": "Modern web development techniques and tools..."
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Public.blog.posts.ai.title", "key": "Public.blog.posts.ai.title",
@ -7658,7 +7664,7 @@
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Public.services.integration.features.Api", "key": "Public.services.integration.features.api",
"tr": "Api Entegrasyonu", "tr": "Api Entegrasyonu",
"en": "Api Integration" "en": "Api Integration"
}, },

View file

@ -0,0 +1,281 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform;
/// <summary>
/// Veritabanı henüz hazır değilken çalışan minimal kurulum uygulaması.
/// Tam ABP stack yüklemez; sadece /api/setup/* endpointlerini sunar.
/// </summary>
internal static class SetupAppRunner
{
// Veritabanı Hazırlık Kontrolü
/// <summary>
/// DB var mı ve AbpRoles tablosu oluşmuş mu diye kontrol eder.
/// Boş DB veya bağlantı hatası durumunda false döner.
/// </summary>
public static bool DatabaseIsReady(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (string.IsNullOrWhiteSpace(connectionString))
return false;
try
{
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
return SqlServerIsReady(connectionString);
#pragma warning disable CS0162
return true; // Diğer sağlayıcılar için geçici — ileride PostgreSQL desteği eklenecek
#pragma warning restore CS0162
}
catch (Exception ex)
{
Log.Warning("Veritabanı hazırlık kontrolü başarısız: {Error}", ex.Message);
return false;
}
}
private static bool SqlServerIsReady(string connectionString)
{
var csb = new SqlConnectionStringBuilder(connectionString);
var dbName = csb.InitialCatalog;
if (string.IsNullOrEmpty(dbName))
return false;
// 1) master'a bağlan — DB varlığını kontrol et
var masterCsb = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "master",
ConnectTimeout = 8
};
using var masterConn = new SqlConnection(masterCsb.ConnectionString);
masterConn.Open();
using var dbCheck = new SqlCommand(
"SELECT COUNT(1) FROM sys.databases WHERE name = @n", masterConn);
dbCheck.Parameters.AddWithValue("@n", dbName);
if ((int)dbCheck.ExecuteScalar() == 0)
return false;
// 2) Hedef DB'ye bağlan — AbpRoles tablosunun varlığını kontrol et
csb.ConnectTimeout = 8;
using var dbConn = new SqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new SqlCommand(
"SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'AbpRoles'",
dbConn);
return (int)tableCheck.ExecuteScalar() > 0;
}
// Minimal Kurulum Uygulaması
public static async Task<int> RunAsync(string[] args, IConfiguration configuration)
{
Log.Warning("Veritabanı hazır değil — kurulum modu başlatılıyor.");
var builder = WebApplication.CreateBuilder(args);
var extraOrigins = (configuration["App:CorsOrigins"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var baseDomain = configuration["App:BaseDomain"]?.Trim();
builder.Services.AddCors(o => o.AddPolicy("Setup", policy =>
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false;
var host = uri.Host.ToLowerInvariant();
if (host is "localhost" or "127.0.0.1" or "[::1]")
return true;
if (!string.IsNullOrWhiteSpace(baseDomain))
{
var bd = baseDomain.ToLowerInvariant();
if (host == bd || host.EndsWith("." + bd))
return true;
}
foreach (var o in extraOrigins)
if (Uri.TryCreate(o, UriKind.Absolute, out var eo) &&
eo.Host.Equals(host, StringComparison.OrdinalIgnoreCase))
return true;
return false;
})));
builder.Host.UseSerilog();
var app = builder.Build();
app.UseCors("Setup");
app.MapGet("/api/setup/status", (IConfiguration cfg) =>
Results.Ok(new { dbExists = DatabaseIsReady(cfg) }));
app.MapGet("/api/setup/migrate", async (IConfiguration cfg, IHostEnvironment env,
IHostApplicationLifetime lifetime, HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.ContentType = "text/event-stream; charset=utf-8";
ctx.Response.Headers["Cache-Control"] = "no-cache, no-store";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.Body.FlushAsync(ct);
async Task Send(string level, string message)
{
try
{
var payload = JsonSerializer.Serialize(new { level, message });
await ctx.Response.WriteAsync($"data: {payload}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
catch { }
}
if (DatabaseIsReady(cfg))
{
await Send("warn", "Veritabanı zaten hazır. Migration atlanıyor.");
await Send("done", "Tamamlandı.");
return;
}
var migratorPath = cfg["Setup:MigratorPath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "Sozsoft.Platform.DbMigrator"));
await Send("info", "Veritabanı migration ve seed başlatılıyor...");
await Send("info", $"Migrator yolu: {migratorPath}");
var extraArgs = cfg["Setup:MigratorArgs"] ?? "--Seed=true";
string fileName;
string arguments;
string workingDirectory;
if (migratorPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(migratorPath))
{
// Doğrudan DLL yolu verilmiş — "--" separator YOK, doğrudan argüman
fileName = "dotnet";
arguments = $"\"{migratorPath}\" {extraArgs}";
workingDirectory = Path.GetDirectoryName(migratorPath)!;
}
else if (Directory.Exists(migratorPath))
{
// Klasör verilmiş — içinde publish edilmiş DLL var mı?
var dllFiles = Directory.GetFiles(migratorPath, "*.DbMigrator.dll", SearchOption.TopDirectoryOnly);
if (dllFiles.Length == 0)
dllFiles = Directory.GetFiles(migratorPath, "*Migrator*.dll", SearchOption.TopDirectoryOnly);
if (dllFiles.Length > 0)
{
// Publish çıktısı — SDK gerekmez, "--" separator YOK
fileName = "dotnet";
arguments = $"\"{dllFiles[0]}\" {extraArgs}";
workingDirectory = migratorPath;
}
else
{
// Kaynak proje klasörü — geliştirme ortamı, "--" gerekli
fileName = "dotnet";
arguments = $"run --project \"{migratorPath}\" -- {extraArgs}";
workingDirectory = migratorPath;
}
}
else
{
await Send("error", $"Migrator yolu bulunamadı veya geçersiz: {migratorPath}");
await Send("done", "Hata ile sonlandı.");
return;
}
await Send("info", $"Çalıştırılıyor: {fileName} {arguments}");
Process? process = null;
try
{
process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory,
}
};
process.Start();
async Task ReadStream(StreamReader reader, string level)
{
try
{
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync(ct);
if (line != null) await Send(level, line);
}
}
catch (OperationCanceledException) { }
}
await Task.WhenAll(
ReadStream(process.StandardOutput, "info"),
ReadStream(process.StandardError, "warn"));
await process.WaitForExitAsync(ct);
if (process.ExitCode == 0)
{
await Send("success", "Migration ve seed başarıyla tamamlandı.");
await Send("restart", "Uygulama sunucusu yeniden başlatılıyor...");
await Send("done", "Tamamlandı.");
_ = Task.Delay(1500).ContinueWith(_ => lifetime.StopApplication());
}
else
{
await Send("error", $"Migration başarısız. Çıkış kodu: {process.ExitCode}");
await Send("done", "Hata ile sonlandı.");
}
}
catch (OperationCanceledException)
{
await Send("warn", "Migration isteği iptal edildi.");
}
catch (Exception ex)
{
await Send("error", $"Migration hatası: {ex.Message}");
await Send("done", "Hata ile sonlandı.");
}
finally
{
process?.Dispose();
}
});
app.MapFallback(() => Results.StatusCode(503));
await app.RunAsync();
return 0;
}
}

View file

@ -5,7 +5,6 @@ using System.Threading.Tasks;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.DynamicServices; using Sozsoft.Platform.DynamicServices;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -26,6 +25,24 @@ public class Program
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? ""}.json", true) .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? ""}.json", true)
.Build(); .Build();
// ── Veritabanı varlık kontrolü ────────────────────────────────────────
// Serilog SQL sink kurulmadan ve ABP modülleri yüklenmeden önce yapılmalı.
// DB yoksa minimal setup uygulaması çalıştırılır.
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Warning()
.WriteTo.Console()
.CreateLogger();
if (!SetupAppRunner.DatabaseIsReady(configuration))
{
var setupResult = await SetupAppRunner.RunAsync(args, configuration);
if (setupResult != 0)
return setupResult;
// Migration başarılı — DB artık hazır, tam ABP başlatmasına geç
Log.Warning("Migration tamamlandı — tam uygulama başlatılıyor.");
}
// ── Veritabanı mevcut — tam ABP başlatma ─────────────────────────────
var columnWriters = new Dictionary<string, ColumnWriterBase> var columnWriters = new Dictionary<string, ColumnWriterBase>
{ {

View file

@ -24,6 +24,10 @@
"StringEncryption": { "StringEncryption": {
"DefaultPassPhrase": "UQpiYfT79zRZ3yYH" "DefaultPassPhrase": "UQpiYfT79zRZ3yYH"
}, },
"Setup": {
"MigratorPath": "/srv/Sozsoft.Platform.DbMigrator",
"MigratorArgs": "--Seed=true"
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information" "Default": "Information"

View file

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc;
namespace Sozsoft.Platform.Controllers;
/// <summary>
/// Tam ABP uygulaması çalışırken setup durumunu döner.
/// SetupAppRunner'ın aynı endpoint'i DB hazır olmadığında dbExists=false döner.
/// </summary>
[ApiController]
[Route("api/setup")]
public class SetupController : AbpControllerBase
{
[HttpGet("status")]
public IActionResult Status() => Ok(new { dbExists = true });
}

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { ComponentInfo } from '../../proxy/developerKit/componentInfo'; import { ComponentInfo } from '../../proxy/developerKit/componentInfo';
import { Button } from '../ui';
interface ComponentSelectorProps { interface ComponentSelectorProps {
components: ComponentInfo[]; components: ComponentInfo[];
@ -20,13 +21,15 @@ const ComponentSelector: React.FC<ComponentSelectorProps> = ({
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
Select Component Select Component
</label> </label>
<button <Button
variant='solid'
size="xs"
onClick={onRefresh} onClick={onRefresh}
className="px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 transition-colors" className="px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 transition-colors"
title="Refresh component list" title="Refresh component list"
> >
Refresh Refresh
</button> </Button>
</div> </div>
<select <select
value={selectedComponentId || ''} value={selectedComponentId || ''}

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import TailwindModal from "./TailwindModal"; import TailwindModal from "./TailwindModal";
import { ComponentInfo, HookInfo, PropertyInfo } from "../../proxy/developerKit/componentInfo"; import { ComponentInfo, HookInfo, PropertyInfo } from "../../proxy/developerKit/componentInfo";
import { getComponentDefinition } from "./data/componentDefinitions"; import { getComponentDefinition } from "./data/componentDefinitions";
import { Button } from "../ui";
interface PropertyPanelProps { interface PropertyPanelProps {
selectedComponent: ComponentInfo | null; selectedComponent: ComponentInfo | null;
@ -525,7 +526,9 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
</div> </div>
</div> </div>
{/* Sil Butonu */} {/* Sil Butonu */}
<button <Button
variant="solid"
size="xs"
className="mr-2 px-3 py-1 rounded bg-red-500 text-white hover:bg-red-600 transition-colors text-sm" className="mr-2 px-3 py-1 rounded bg-red-500 text-white hover:bg-red-600 transition-colors text-sm"
onClick={() => { onClick={() => {
if (selectedComponent) { if (selectedComponent) {
@ -541,28 +544,32 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
title="Komponenti Sil" title="Komponenti Sil"
> >
Sil Sil
</button> </Button>
</div> </div>
{/* Footer Action Buttons - her iki tabda da sabit */} {/* Footer Action Buttons - her iki tabda da sabit */}
<div className="p-4 border-t"> <div className="p-4 border-t">
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
size="xs"
variant="solid"
onClick={ onClick={
activeTab === "props" activeTab === "props"
? handleApplyPropChanges ? handleApplyPropChanges
: handleApplyHookChanges : handleApplyHookChanges
} }
disabled={activeTab === "props" ? !hasChanges : !hasHookChanges} disabled={activeTab === "props" ? !hasChanges : !hasHookChanges}
className={`flex-1 px-4 py-2 rounded-md font-medium transition-colors ${ className={`flex-1 rounded-md font-medium transition-colors ${
(activeTab === "props" ? hasChanges : hasHookChanges) (activeTab === "props" ? hasChanges : hasHookChanges)
? "bg-green-500 text-white hover:bg-green-600" ? "bg-green-500 text-white hover:bg-green-600"
: "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-gray-300 text-gray-500 cursor-not-allowed"
}`} }`}
> >
Uygula Uygula
</button> </Button>
<button <Button
size="xs"
variant="default"
onClick={ onClick={
activeTab === "props" activeTab === "props"
? handleResetChanges ? handleResetChanges
@ -572,20 +579,20 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
} }
} }
disabled={activeTab === "props" ? !hasChanges : !hasHookChanges} disabled={activeTab === "props" ? !hasChanges : !hasHookChanges}
className={`flex-1 px-4 py-2 rounded-md font-medium transition-colors ${ className={`flex-1 rounded-md font-medium transition-colors ${
(activeTab === "props" ? hasChanges : hasHookChanges) (activeTab === "props" ? hasChanges : hasHookChanges)
? "bg-red-500 text-white hover:bg-red-600" ? "bg-red-500 text-white hover:bg-red-600"
: "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-gray-300 text-gray-500 cursor-not-allowed"
}`} }`}
> >
İptal İptal
</button> </Button>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
{activeTab === "props" && ( {activeTab === "props" && (
<div className="flex-1 overflow-y-auto p-4 max-h-[calc(100vh-200px)]"> <div className="flex-1 text-black overflow-y-auto p-4 max-h-[calc(100vh-200px)]">
<h3 className="text-md font-medium text-gray-800 mb-4">Properties</h3> <h3 className="text-md font-medium text-gray-800 mb-4">Properties</h3>
{/* Properties */} {/* Properties */}
{properties.length > 0 && ( {properties.length > 0 && (

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { searchTailwindClasses, TAILWIND_CLASSES } from './data/tailwindClasses'; import { searchTailwindClasses, TAILWIND_CLASSES } from './data/tailwindClasses';
import { Button } from '../ui';
interface TailwindModalProps { interface TailwindModalProps {
isOpen: boolean; isOpen: boolean;
@ -61,7 +62,7 @@ const TailwindModal: React.FC<TailwindModalProps> = ({
</div> </div>
{/* Search and Filter */} {/* Search and Filter */}
<div className="p-4 border-b bg-gray-50"> <div className="p-4 border-b bg-gray-50 text-black">
<div className="flex gap-4 mb-4"> <div className="flex gap-4 mb-4">
<input <input
type="text" type="text"
@ -106,25 +107,15 @@ const TailwindModal: React.FC<TailwindModalProps> = ({
key={`${className}-${index}`} key={`${className}-${index}`}
className="group relative" className="group relative"
> >
<button <Button
variant='default'
onClick={() => handleClassSelect(className)} onClick={() => handleClassSelect(className)}
className="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-blue-100 rounded border hover:border-blue-300 transition-colors" className="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-blue-100 rounded border hover:border-blue-300 transition-colors"
> >
<span className="font-mono text-xs text-gray-600"> <span className="font-mono text-xs text-gray-600">
{className} {className}
</span> </span>
</button> </Button>
{/* Add to current value button */}
{currentValue && (
<button
onClick={() => handleAddToCurrentValue(className)}
className="absolute top-1 right-1 w-6 h-6 bg-blue-500 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-600"
title="Add to current value"
>
+
</button>
)}
</div> </div>
))} ))}
</div> </div>
@ -137,18 +128,20 @@ const TailwindModal: React.FC<TailwindModalProps> = ({
{filteredClasses.length} classes found {filteredClasses.length} classes found
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant='default'
onClick={() => onSelectClass('')} onClick={() => onSelectClass('')}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors" className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
> >
Clear Clear
</button> </Button>
<button <Button
variant='solid'
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
> >
Close Close
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -60,6 +60,11 @@ const Layout = () => {
}, [routes, currentPath]) }, [routes, currentPath])
const AppLayout = useMemo(() => { const AppLayout = useMemo(() => {
// 0) Setup path ise minimal blank layout
if (currentPath === '/setup') {
return layouts[LAYOUT_TYPE_BLANK]
}
// 1) Admin path ise, route bulunmasa bile admin layout'u göster // 1) Admin path ise, route bulunmasa bile admin layout'u göster
if (isAdminPath) { if (isAdminPath) {
return layouts[layoutType] return layouts[layoutType]
@ -78,7 +83,7 @@ const Layout = () => {
return AuthLayout return AuthLayout
} }
return PublicLayout return PublicLayout
}, [isAdminPath, route, layoutType, authenticated]) }, [isAdminPath, route, layoutType, authenticated, currentPath])
if (loading) { if (loading) {
return ( return (

View file

@ -8,18 +8,43 @@ import { useSetting } from '@/utils/hooks/useSetting'
import useTabFocus from '@/utils/hooks/useTabFocus' import useTabFocus from '@/utils/hooks/useTabFocus'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { useNavigate, useLocation } from 'react-router-dom'
import { getSetupStatus } from '@/services/setup.service'
import { ROUTES_ENUM } from '@/routes/route.constant'
let didInit = false let didInit = false
const Theme = (props: CommonProps) => { const Theme = (props: CommonProps) => {
// ABP App Config'i uygulama acilirken al // ABP App Config'i uygulama acilirken al
const { getConfig } = useStoreActions((a) => a.abpConfig) const { getConfig } = useStoreActions((a) => a.abpConfig)
const { setSetupMode } = useStoreActions((a) => a.base.common)
const navigate = useNavigate()
const location = useLocation()
useEffect(() => { useEffect(() => {
if (!didInit) { if (!didInit) {
didInit = true didInit = true
// Direkt /setup'a gelindiyse — hemen setupMode=true yap (loading gate açılsın)
if (location.pathname === ROUTES_ENUM.setup) {
setSetupMode(true)
return
}
// Veritabanı var mı kontrol et; yoksa setup sayfasına yönlendir
getSetupStatus()
.then((res) => {
if (!res.data.dbExists) {
setSetupMode(true)
navigate(ROUTES_ENUM.setup, { replace: true })
} else {
getConfig(false) getConfig(false)
} }
})
.catch(() => {
getConfig(false)
})
}
}, []) }, [])
function getThemeStyle() { function getThemeStyle() {

View file

@ -1,6 +1,6 @@
// DynamicRouter.tsx // DynamicRouter.tsx
import React from 'react' import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { mapDynamicRoutes } from './dynamicRouteLoader' import { mapDynamicRoutes } from './dynamicRouteLoader'
import { useDynamicRoutes } from './dynamicRoutesContext' import { useDynamicRoutes } from './dynamicRoutesContext'
import { useComponents } from '@/contexts/ComponentContext' import { useComponents } from '@/contexts/ComponentContext'
@ -13,15 +13,18 @@ import { hasSubdomain } from '@/utils/subdomain'
// AccessDenied ve NotFound'u dinamiklikten çıkarıyoruz // AccessDenied ve NotFound'u dinamiklikten çıkarıyoruz
const AccessDenied = React.lazy(() => import('@/views/AccessDenied')) const AccessDenied = React.lazy(() => import('@/views/AccessDenied'))
const NotFound = React.lazy(() => import('@/views/NotFound')) const NotFound = React.lazy(() => import('@/views/NotFound'))
const DatabaseSetup = React.lazy(() => import('@/views/setup/DatabaseSetup'))
export const DynamicRouter: React.FC = () => { export const DynamicRouter: React.FC = () => {
const { routes, loading, error } = useDynamicRoutes() const { routes, loading, error } = useDynamicRoutes()
const { registeredComponents, renderComponent, isComponentRegistered } = useComponents() const { registeredComponents, renderComponent, isComponentRegistered } = useComponents()
const location = useLocation()
const dynamicRoutes = React.useMemo(() => mapDynamicRoutes(routes), [routes]) const dynamicRoutes = React.useMemo(() => mapDynamicRoutes(routes), [routes])
if (loading) return <div>Loading...</div> // /setup path'inde loading bekleme — setup route her zaman erişilebilir olmalı
if (error) return <div>Hata: {error}</div> if (loading && location.pathname !== '/setup') return <div>Loading...</div>
if (error && location.pathname !== '/setup') return <div>Hata: {error}</div>
return ( return (
<Routes> <Routes>
@ -126,6 +129,16 @@ export const DynamicRouter: React.FC = () => {
</React.Suspense> </React.Suspense>
} }
/> />
{/* İlk kurulum — veritabanı mevcut değilse gösterilir */}
<Route
path={ROUTES_ENUM.setup}
element={
<React.Suspense fallback={<div>Loading...</div>}>
<DatabaseSetup />
</React.Suspense>
}
/>
</Routes> </Routes>
) )
} }

View file

@ -19,6 +19,7 @@ export const useDynamicRoutes = () => {
export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties) const extraProperties = useStoreState((state) => state.abpConfig?.config?.extraProperties)
const setupMode = useStoreState((state) => state.base.common.setupMode)
const [routes, setRoutes] = useState<RouteDto[]>([]) const [routes, setRoutes] = useState<RouteDto[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -48,8 +49,12 @@ export const DynamicRoutesProvider: React.FC<{ children: React.ReactNode }> = ({
useEffect(() => { useEffect(() => {
if (extraProperties) { if (extraProperties) {
loadRoutesFromConfig() loadRoutesFromConfig()
} else if (setupMode) {
// Veritabanı mevcut değil — setup modunda loading'i kapat
setLoading(false)
setRoutes([])
} }
}, [extraProperties]) }, [extraProperties, setupMode])
return ( return (
<DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}> <DynamicRoutesContext.Provider value={{ routes, loading, error, reload: loadRoutesFromConfig }}>

View file

@ -1,4 +1,5 @@
export const ROUTES_ENUM = { export const ROUTES_ENUM = {
setup: '/setup',
public: { public: {
home: '/home', home: '/home',
about: '/about', about: '/about',

View file

@ -5,10 +5,13 @@ import {
} from '../proxy/config/models' } from '../proxy/config/models'
import apiService from './api.service' import apiService from './api.service'
export const applicationConfigurationUrl = (includeLocalizationResources: boolean) =>
`/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`
export const getAppConfig = (includeLocalizationResources: boolean) => export const getAppConfig = (includeLocalizationResources: boolean) =>
apiService.fetchData<ApplicationConfigurationDto>({ apiService.fetchData<ApplicationConfigurationDto>({
method: 'GET', method: 'GET',
url: `/api/abp/application-configuration?includeLocalizationResources=${includeLocalizationResources}`, url: applicationConfigurationUrl(includeLocalizationResources),
}) })
export const getLocalizations = ({ export const getLocalizations = ({

View file

@ -1,573 +0,0 @@
import { ClassroomAttendanceDto, ClassroomChatDto, HandRaiseDto } from '@/proxy/classroom/models'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { store } from '@/store/store'
import * as signalR from '@microsoft/signalr'
import { toast } from '@/components/ui'
import Notification from '@/components/ui/Notification'
export class SignalRService {
private connection!: signalR.HubConnection
private isConnected: boolean = false
private currentSessionId?: string
private isKicked: boolean = false
private onAttendanceUpdate?: (record: ClassroomAttendanceDto) => void
private onParticipantJoined?: (
userId: string,
name: string,
isTeacher: boolean,
isActive: boolean,
) => void
private onParticipantLeft?: (payload: {
userId: string
sessionId: string
userName: string
}) => void
private onChatMessage?: (message: ClassroomChatDto) => void
private onParticipantMuted?: (userId: string, isMuted: boolean) => void
private onHandRaiseReceived?: (studentId: string) => void
private onHandRaiseDismissed?: (studentId: string) => void
private onOfferReceived?: (fromUserId: string, offer: RTCSessionDescriptionInit) => void
private onAnswerReceived?: (fromUserId: string, answer: RTCSessionDescriptionInit) => void
private onIceCandidateReceived?: (fromUserId: string, candidate: RTCIceCandidateInit) => void
private onForceCleanup?: () => void
constructor() {
const { auth } = store.getState()
this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/classroomhub`, {
accessTokenFactory: () => auth.session.token || '',
})
.configureLogging(signalR.LogLevel.Information)
.build()
this.setupEventHandlers()
}
private setupEventHandlers() {
if (!this.connection) return
this.connection.on('AttendanceUpdated', (record: ClassroomAttendanceDto) => {
this.onAttendanceUpdate?.(record)
})
this.connection.on(
'ParticipantJoined',
(userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
this.onParticipantJoined?.(userId, name, isTeacher, isActive)
},
)
this.connection.on(
'ParticipantLeft',
(payload: { userId: string; sessionId: string; userName: string }) => {
this.onParticipantLeft?.(payload)
},
)
this.connection.on('ChatMessage', (message: any) => {
this.onChatMessage?.(message)
})
this.connection.on('ParticipantMuted', (userId: string, isMuted: boolean) => {
this.onParticipantMuted?.(userId, isMuted)
})
this.connection.on('HandRaiseReceived', (payload: any) => {
this.onHandRaiseReceived?.(payload.studentId)
})
this.connection.on('HandRaiseDismissed', (payload: any) => {
this.onHandRaiseDismissed?.(payload.studentId)
})
this.connection.on('ReceiveOffer', (fromUserId: string, offer: RTCSessionDescriptionInit) => {
this.onOfferReceived?.(fromUserId, offer)
})
this.connection.on('ReceiveAnswer', (fromUserId: string, answer: RTCSessionDescriptionInit) => {
this.onAnswerReceived?.(fromUserId, answer)
})
this.connection.on(
'ReceiveIceCandidate',
(fromUserId: string, candidate: RTCIceCandidateInit) => {
this.onIceCandidateReceived?.(fromUserId, candidate)
},
)
this.connection.onreconnected(async () => {
this.isConnected = true
toast.push(<Notification title="🔄 Bağlantı tekrar kuruldu" type="success" />, {
placement: 'top-end',
})
if (this.currentSessionId && store.getState().auth.user) {
const u = store.getState().auth.user
await this.joinClass(this.currentSessionId, u.id, u.name, u.role === 'teacher', true)
}
})
this.connection.onclose(async () => {
if (this.isKicked) {
toast.push(
<Notification title="⚠️ Bağlantı koptu, yeniden bağlanılıyor..." type="warning" />,
{ placement: 'top-end' },
)
this.isConnected = false
this.currentSessionId = undefined
return
}
this.isConnected = false
try {
if (this.currentSessionId) {
await this.connection.invoke('LeaveClass', this.currentSessionId)
}
} finally {
this.currentSessionId = undefined
}
})
this.connection.on('Error', (message: string) => {
toast.push(<Notification title={`❌ Hata: ${message}`} type="danger" />, {
placement: 'top-end',
})
})
this.connection.on('Warning', (message: string) => {
toast.push(<Notification title={`⚠️ Uyarı: ${message}`} type="warning" />, {
placement: 'top-end',
})
})
this.connection.on('Info', (message: string) => {
toast.push(<Notification title={` Bilgi: ${message}`} type="info" />, {
placement: 'top-end',
})
})
this.connection.onreconnecting(() => {
if (this.isKicked) {
toast.push(
<Notification
title="❌ Sınıftan çıkarıldığınız için yeniden bağlanma engellendi"
type="danger"
/>,
)
this.connection.stop()
throw new Error('Reconnect blocked after kick')
}
})
this.connection.on('ForceDisconnect', async (message: string) => {
this.isKicked = true
toast.push(<Notification title={`❌ Sınıftan çıkarıldınız: ${message}`} type="danger" />, {
placement: 'top-end',
})
if (this.onForceCleanup) {
this.onForceCleanup()
}
try {
await this.connection.stop()
} catch {}
this.isConnected = false
if (this.currentSessionId && store.getState().auth.user) {
this.onParticipantLeft?.({
userId: store.getState().auth.user.id,
sessionId: this.currentSessionId,
userName: store.getState().auth.user.name,
})
}
this.currentSessionId = undefined
window.location.href = ROUTES_ENUM.protected.coordinator.classroom.classes
})
}
async start(): Promise<void> {
try {
const startPromise = this.connection.start()
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Bağlantı zaman aşımına uğradı')), 10000),
)
await Promise.race([startPromise, timeout])
this.isConnected = true
toast.push(<Notification title="✅ Bağlantı kuruldu" type="success" />, {
placement: 'top-end',
})
} catch {
toast.push(
<Notification
title="⚠️ Sunucuya bağlanılamadı. Lütfen sayfayı yenileyin veya internet bağlantınızı kontrol edin."
type="danger"
/>,
{ placement: 'top-end' },
)
this.isConnected = false
}
}
async joinClass(
sessionId: string,
userId: string,
userName: string,
isTeacher: boolean,
isActive: boolean,
): Promise<void> {
if (!this.isConnected) {
toast.push(
<Notification
title="⚠️ Bağlantı yok. Sınıfa katılmadan önce bağlantıyı kontrol edin."
type="warning"
/>,
)
return
}
this.currentSessionId = sessionId
try {
await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive)
} catch {
toast.push(<Notification title="❌ Sınıfa katılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
async leaveClass(sessionId: string): Promise<void> {
const { auth } = store.getState()
if (!this.isConnected) {
this.onParticipantLeft?.({ userId: auth.user.id, sessionId, userName: auth.user.name })
return
}
try {
await this.connection.invoke('LeaveClass', sessionId)
this.currentSessionId = undefined
} catch {
toast.push(<Notification title="⚠️ Çıkış başarısız" type="warning" />, {
placement: 'top-end',
})
}
}
async sendChatMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
isTeacher: boolean,
): Promise<void> {
if (!this.isConnected) {
const chatMessage: ClassroomChatDto = {
id: crypto.randomUUID(),
sessionId,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher,
messageType: 'public',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke(
'SendChatMessage',
sessionId,
senderId,
senderName,
message,
isTeacher,
'public',
)
} catch {
toast.push(<Notification title="❌ Mesaj gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async sendPrivateMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
recipientId: string,
recipientName: string,
isTeacher: boolean,
): Promise<void> {
if (!this.isConnected) {
const chatMessage: ClassroomChatDto = {
id: crypto.randomUUID(),
sessionId,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher,
recipientId,
recipientName,
messageType: 'private',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke(
'SendPrivateMessage',
sessionId,
senderId,
senderName,
message,
recipientId,
recipientName,
isTeacher,
'private',
)
} catch {
toast.push(<Notification title="❌ Özel mesaj gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async sendAnnouncement(
sessionId: string,
senderId: string,
senderName: string,
message: string,
isTeacher: boolean,
): Promise<void> {
if (!this.isConnected) {
const chatMessage: ClassroomChatDto = {
id: crypto.randomUUID(),
sessionId,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher,
messageType: 'announcement',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke(
'SendAnnouncement',
sessionId,
senderId,
senderName,
message,
isTeacher,
)
} catch {
toast.push(<Notification title="❌ Duyuru gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async muteParticipant(
sessionId: string,
userId: string,
isMuted: boolean,
isTeacher: boolean,
): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onParticipantMuted?.(userId, isMuted)
}, 100)
return
}
try {
await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher)
} catch {
toast.push(<Notification title="⚠️ Katılımcı susturulamadı" type="warning" />, {
placement: 'top-end',
})
}
}
async raiseHand(sessionId: string, studentId: string, studentName: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseReceived?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('RaiseHand', sessionId, studentId, studentName)
} catch {
toast.push(<Notification title="❌ El kaldırma başarısız" type="danger" />, {
placement: 'top-end',
})
}
}
async kickParticipant(sessionId: string, participantId: string, userName: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onParticipantLeft?.({ userId: participantId, sessionId, userName })
}, 100)
return
}
try {
await this.connection.invoke('KickParticipant', sessionId, participantId)
} catch {
toast.push(<Notification title="❌ Katılımcı atılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
async approveHandRaise(sessionId: string, studentId: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseDismissed?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('ApproveHandRaise', sessionId, studentId)
} catch {
toast.push(<Notification title="⚠️ El kaldırma onayı başarısız" type="warning" />, {
placement: 'top-end',
})
}
}
async dismissHandRaise(sessionId: string, studentId: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseDismissed?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('DismissHandRaise', sessionId, studentId)
} catch {
toast.push(<Notification title="⚠️ El indirme başarısız" type="warning" />, {
placement: 'top-end',
})
}
}
async sendOffer(sessionId: string, targetUserId: string, offer: RTCSessionDescriptionInit) {
if (!this.isConnected) return
await this.connection.invoke('SendOffer', sessionId, targetUserId, offer)
}
async sendAnswer(sessionId: string, targetUserId: string, answer: RTCSessionDescriptionInit) {
if (!this.isConnected) return
await this.connection.invoke('SendAnswer', sessionId, targetUserId, answer)
}
async sendIceCandidate(sessionId: string, targetUserId: string, candidate: RTCIceCandidateInit) {
if (!this.isConnected) return
await this.connection.invoke('SendIceCandidate', sessionId, targetUserId, candidate)
}
setExistingParticipantsHandler(callback: (participants: any[]) => void) {
this.connection.on('ExistingParticipants', callback)
}
setAttendanceUpdatedHandler(callback: (record: ClassroomAttendanceDto) => void) {
this.onAttendanceUpdate = callback
}
setParticipantJoinHandler(
callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void,
) {
this.onParticipantJoined = callback
}
setParticipantLeaveHandler(
callback: (payload: { userId: string; sessionId: string; userName: string }) => void,
) {
this.onParticipantLeft = callback
}
setChatMessageReceivedHandler(callback: (message: ClassroomChatDto) => void) {
this.onChatMessage = callback
}
setParticipantMutedHandler(callback: (userId: string, isMuted: boolean) => void) {
this.onParticipantMuted = callback
}
setHandRaiseReceivedHandler(callback: (studentId: string) => void) {
this.onHandRaiseReceived = callback
}
setHandRaiseDismissedHandler(callback: (studentId: string) => void) {
this.onHandRaiseDismissed = callback
}
setOfferReceivedHandler(
callback: (fromUserId: string, offer: RTCSessionDescriptionInit) => void,
) {
this.onOfferReceived = callback
}
setAnswerReceivedHandler(
callback: (fromUserId: string, answer: RTCSessionDescriptionInit) => void,
) {
this.onAnswerReceived = callback
}
setIceCandidateReceivedHandler(
callback: (fromUserId: string, candidate: RTCIceCandidateInit) => void,
) {
this.onIceCandidateReceived = callback
}
async disconnect(): Promise<void> {
if (this.isConnected && this.currentSessionId) {
try {
await this.connection.invoke('LeaveClass', this.currentSessionId)
} catch {
toast.push(<Notification title="⚠️ Bağlantı koparılırken hata" type="warning" />, {
placement: 'top-end',
})
}
}
if (this.connection) {
await this.connection.stop()
}
this.isConnected = false
this.currentSessionId = undefined
}
getConnectionState(): boolean {
return this.isConnected
}
setForceCleanupHandler(callback: () => void) {
this.onForceCleanup = callback
}
}

View file

@ -1,358 +0,0 @@
import { toast } from '@/components/ui'
import Notification from '@/components/ui/Notification'
export class WebRTCService {
private peerConnections: Map<string, RTCPeerConnection> = new Map()
private retryCounts: Map<string, number> = new Map()
private maxRetries = 3
private signalRService: any
private sessionId: string = ''
private localStream: MediaStream | null = null
private onRemoteStream?: (userId: string, stream: MediaStream) => void
private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void
private candidateBuffer: Map<string, RTCIceCandidateInit[]> = new Map()
private rtcConfiguration: RTCConfiguration = {
iceServers: [
{
urls: [
'stun:turn.sozsoft.com:3478',
'turn:turn.sozsoft.com:3478?transport=udp',
'turn:turn.sozsoft.com:3478?transport=tcp',
'turns:turn.sozsoft.com:5349?transport=tcp',
],
username: 'webrtc',
credential: 'strongpassword123',
},
],
}
async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
this.localStream.getAudioTracks().forEach((track) => (track.enabled = enableAudio))
this.localStream.getVideoTracks().forEach((track) => (track.enabled = enableVideo))
return this.localStream
} catch {
toast.push(
<Notification
title="❌ Kamera/Mikrofon erişilemedi. Tarayıcı ayarlarınızı veya izinleri kontrol edin."
type="danger"
/>,
{ placement: 'top-end' },
)
throw new Error('Media devices access failed')
}
}
async createPeerConnection(userId: string): Promise<RTCPeerConnection> {
const peerConnection = new RTCPeerConnection(this.rtcConfiguration)
this.peerConnections.set(userId, peerConnection)
this.retryCounts.set(userId, 0)
if (this.localStream) {
this.localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, this.localStream!)
})
}
peerConnection.ontrack = (event) => {
const [remoteStream] = event.streams
this.onRemoteStream?.(userId, remoteStream)
}
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
this.onIceCandidate?.(userId, event.candidate)
}
}
peerConnection.onconnectionstatechange = async () => {
const state = peerConnection.connectionState
if (state === 'closed') {
this.closePeerConnection(userId)
}
if (state === 'failed') {
let retries = this.retryCounts.get(userId) ?? 0
if (retries < this.maxRetries) {
toast.push(
<Notification
title={`⚠️ Bağlantı başarısız, yeniden deneniyor (${retries + 1}/${this.maxRetries})`}
type="warning"
/>,
)
this.retryCounts.set(userId, retries + 1)
await this.restartIce(peerConnection, userId)
} else {
toast.push(
<Notification
title={`❌ Bağlantı kurulamadı (${this.maxRetries} deneme başarısız).`}
type="danger"
/>,
{ placement: 'top-end' },
)
this.closePeerConnection(userId)
}
}
}
if (this.candidateBuffer.has(userId)) {
for (const cand of this.candidateBuffer.get(userId)!) {
try {
await peerConnection.addIceCandidate(cand)
} catch {
toast.push(
<Notification
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
type="warning"
/>,
)
}
}
this.candidateBuffer.delete(userId)
}
return peerConnection
}
setSignalRService(signalRService: any, sessionId: string) {
this.signalRService = signalRService
this.sessionId = sessionId
}
setIceCandidateHandler(callback: (userId: string, candidate: RTCIceCandidateInit) => void) {
this.onIceCandidate = callback
}
async createOffer(userId: string): Promise<RTCSessionDescriptionInit> {
const pc = this.peerConnections.get(userId)
if (!pc) throw new Error('Peer connection not found')
try {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return offer
} catch {
toast.push(<Notification title="❌ Offer oluşturulamadı" type="danger" />, {
placement: 'top-end',
})
throw new Error('Offer creation failed')
}
}
async createAnswer(
userId: string,
offer: RTCSessionDescriptionInit,
): Promise<RTCSessionDescriptionInit> {
const pc = this.peerConnections.get(userId)
if (!pc) throw new Error('Peer connection not found')
try {
await pc.setRemoteDescription(offer)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
return answer
} catch {
toast.push(<Notification title="❌ Answer oluşturulamadı" type="danger" />, {
placement: 'top-end',
})
throw new Error('Answer creation failed')
}
}
async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
await peerConnection.setRemoteDescription(answer)
}
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
const pc = this.peerConnections.get(userId)
if (!pc) {
if (!this.candidateBuffer.has(userId)) {
this.candidateBuffer.set(userId, [])
}
this.candidateBuffer.get(userId)!.push(candidate)
return
}
if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') {
try {
await pc.addIceCandidate(candidate)
} catch {
toast.push(
<Notification
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
type="warning"
/>,
)
}
} else {
if (!this.candidateBuffer.has(userId)) {
this.candidateBuffer.set(userId, [])
}
this.candidateBuffer.get(userId)!.push(candidate)
}
}
onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) {
this.onRemoteStream = callback
}
async toggleVideo(enabled: boolean): Promise<void> {
if (!this.localStream) return
let videoTrack = this.localStream.getVideoTracks()[0]
if (videoTrack) {
videoTrack.enabled = enabled
} else if (enabled) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
const newTrack = stream.getVideoTracks()[0]
if (newTrack) {
this.localStream!.addTrack(newTrack)
this.peerConnections.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind)
if (sender) {
sender.replaceTrack(newTrack)
} else {
pc.addTrack(newTrack, this.localStream!)
}
})
}
} catch {
toast.push(<Notification title="❌ Kamera açılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
}
async toggleAudio(enabled: boolean): Promise<void> {
if (!this.localStream) return
let audioTrack = this.localStream.getAudioTracks()[0]
if (audioTrack) {
audioTrack.enabled = enabled
} else if (enabled) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const newTrack = stream.getAudioTracks()[0]
if (newTrack) {
this.localStream!.addTrack(newTrack)
this.peerConnections.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind)
if (sender) {
sender.replaceTrack(newTrack)
} else {
pc.addTrack(newTrack, this.localStream!)
}
})
}
} catch {
toast.push(<Notification title="❌ Mikrofon açılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
}
getLocalStream(): MediaStream | null {
return this.localStream
}
private async restartIce(peerConnection: RTCPeerConnection, userId: string) {
try {
const offer = await peerConnection.createOffer({ iceRestart: true })
await peerConnection.setLocalDescription(offer)
if (this.signalRService) {
await this.signalRService.sendOffer(this.sessionId, userId, offer)
} else {
toast.push(<Notification title="⚠️ Tekrar bağlanma başarısız" type="warning" />, {
placement: 'top-end',
})
}
} catch {
toast.push(<Notification title="❌ ICE restart başarısız" type="danger" />, {
placement: 'top-end',
})
}
}
closePeerConnection(userId: string): void {
const peerConnection = this.peerConnections.get(userId)
if (peerConnection) {
peerConnection.getSenders().forEach((sender) => sender.track?.stop())
peerConnection.close()
this.peerConnections.delete(userId)
this.retryCounts.delete(userId)
}
}
getPeerConnection(userId: string): RTCPeerConnection | undefined {
return this.peerConnections.get(userId)
}
closeAllConnections(): void {
this.peerConnections.forEach((pc) => {
pc.getSenders().forEach((sender) => sender.track?.stop())
pc.close()
})
this.peerConnections.clear()
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop())
this.localStream = null
}
}
addStreamToPeers(stream: MediaStream) {
this.peerConnections.forEach((pc) => {
stream.getTracks().forEach((track) => {
const alreadyHas = pc.getSenders().some((s) => s.track?.id === track.id)
if (!alreadyHas) {
pc.addTrack(track, stream)
track.onended = () => {
this.removeTrackFromPeers(track)
}
}
})
})
}
removeTrackFromPeers(track: MediaStreamTrack) {
this.peerConnections.forEach((pc) => {
pc.getSenders().forEach((sender) => {
if (sender.track === track) {
try {
pc.removeTrack(sender)
} catch {
toast.push(<Notification title="⚠️ Track silinemedi" type="warning" />, {
placement: 'top-end',
})
}
if (sender.track?.readyState !== 'ended') {
sender.track?.stop()
}
}
})
})
}
}

View file

@ -0,0 +1,21 @@
import apiService from './api.service'
export interface SetupStatusDto {
dbExists: boolean
error?: string
}
export const getSetupStatus = () =>
apiService.fetchData<SetupStatusDto>({
method: 'GET',
url: '/api/setup/status',
})
/**
* Returns the SSE URL for migration streaming.
* Usage: new EventSource(getMigrateUrl())
*/
export const getMigrateUrl = (): string => {
const base = import.meta.env.VITE_API_URL ?? ''
return `${base}/api/setup/migrate`
}

View file

@ -13,6 +13,7 @@ export interface BaseStoreModel {
common: { common: {
currentRouteKey: string currentRouteKey: string
tabHasFocus: boolean tabHasFocus: boolean
setupMode: boolean /** Veritabanı mevcut değilse true — setup sayfasına yönlendirme için */
} }
messages: { messages: {
errors: StoreError[] errors: StoreError[]
@ -24,6 +25,7 @@ export interface BaseStoreActions {
common: { common: {
setCurrentRouteKey: Action<BaseStoreModel['common'], string> setCurrentRouteKey: Action<BaseStoreModel['common'], string>
setTabHasFocus: Action<BaseStoreModel['common'], boolean> setTabHasFocus: Action<BaseStoreModel['common'], boolean>
setSetupMode: Action<BaseStoreModel['common'], boolean>
} }
messages: { messages: {
addError: Action<BaseStoreModel['messages'], StoreError> addError: Action<BaseStoreModel['messages'], StoreError>
@ -36,12 +38,12 @@ export interface BaseStoreActions {
export type BaseModel = BaseStoreModel & BaseStoreActions export type BaseModel = BaseStoreModel & BaseStoreActions
const initialState: BaseStoreModel = { const initialState: BaseStoreModel = {
common: { currentRouteKey: '', tabHasFocus: false }, common: { currentRouteKey: '', tabHasFocus: false, setupMode: false },
messages: { messages: {
errors: [], errors: [],
// success: [], // success: [],
warning: [], warning: [],
}, }
} }
export const baseModel: BaseModel = { export const baseModel: BaseModel = {
@ -53,6 +55,9 @@ export const baseModel: BaseModel = {
setTabHasFocus: action((state, payload) => { setTabHasFocus: action((state, payload) => {
state.tabHasFocus = payload state.tabHasFocus = payload
}), }),
setSetupMode: action((state, payload) => {
state.setupMode = payload
}),
}, },
messages: { messages: {
...initialState.messages, ...initialState.messages,

View file

@ -1,3 +1,4 @@
import { Button } from '@/components/ui'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
@ -27,14 +28,15 @@ const NotFoundPage = () => {
{translate('::Public.notFound.message')} {translate('::Public.notFound.message')}
</p> </p>
<div className="flex items-center justify-center font-inter"> <div className="flex items-center justify-center font-inter">
<button <Button
variant='solid'
onClick={() => onClick={() =>
navigate(isAdminPath ? ROUTES_ENUM.protected.dashboard : ROUTES_ENUM.public.home) navigate(isAdminPath ? ROUTES_ENUM.protected.dashboard : ROUTES_ENUM.public.home)
} }
className="px-6 py-3 bg-blue-500 rounded-xl shadow hover:bg-blue-600 transition" className="px-6 py-3 bg-blue-500 rounded-xl shadow hover:bg-blue-600 transition"
> >
{translate('::Public.notFound.button')} {translate('::Public.notFound.button')}
</button> </Button>
</div> </div>
</div> </div>
) )

View file

@ -2,6 +2,7 @@ import { forwardRef } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { FaChevronRight, FaFolder, FaHome } from 'react-icons/fa' import { FaChevronRight, FaFolder, FaHome } from 'react-icons/fa'
import type { BreadcrumbItem } from '@/types/fileManagement' import type { BreadcrumbItem } from '@/types/fileManagement'
import { Button } from '@/components/ui'
export interface BreadcrumbProps { export interface BreadcrumbProps {
items: BreadcrumbItem[] items: BreadcrumbItem[]
@ -17,7 +18,8 @@ const Breadcrumb = forwardRef<HTMLDivElement, BreadcrumbProps>((props, ref) => {
{items.map((item, index) => ( {items.map((item, index) => (
<div key={item.path} className="flex items-center"> <div key={item.path} className="flex items-center">
{index > 0 && <FaChevronRight className="mx-2 h-4 w-4 text-gray-400" />} {index > 0 && <FaChevronRight className="mx-2 h-4 w-4 text-gray-400" />}
<button <Button
size="xs"
onClick={() => onNavigate(item)} onClick={() => onNavigate(item)}
className={classNames( className={classNames(
'flex items-center px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors', 'flex items-center px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
@ -33,7 +35,7 @@ const Breadcrumb = forwardRef<HTMLDivElement, BreadcrumbProps>((props, ref) => {
<FaFolder className="h-4 w-4 mr-1" /> <FaFolder className="h-4 w-4 mr-1" />
)} )}
<span className="truncate max-w-32">{item.name}</span> <span className="truncate max-w-32">{item.name}</span>
</button> </Button>
</div> </div>
))} ))}
</div> </div>

View file

@ -473,20 +473,20 @@ const WizardStep2 = ({
extra={ extra={
selectCommandColumns.length > 0 ? ( selectCommandColumns.length > 0 ? (
<div className="flex items-center gap-2 ml-3"> <div className="flex items-center gap-2 ml-3">
<button <Button
type="button" variant='solid'
onClick={() => onToggleAllColumns(true)} onClick={() => onToggleAllColumns(true)}
className="text-xs px-2 py-0.5 rounded bg-indigo-500 text-white hover:bg-indigo-600" className="text-xs px-2 py-0.5 rounded bg-indigo-500 text-white hover:bg-indigo-600"
> >
{translate('::ListForms.Wizard.Step2.SelectAll') || 'Tümünü Seç'} {translate('::ListForms.Wizard.Step2.SelectAll') || 'Tümünü Seç'}
</button> </Button>
<button <Button
type="button" variant='default'
onClick={() => onToggleAllColumns(false)} onClick={() => onToggleAllColumns(false)}
className="text-xs px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400" className="text-xs px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-400"
> >
{translate('::ListForms.Wizard.Step2.ClearAll') || 'Tümünü Kaldır'} {translate('::ListForms.Wizard.Step2.ClearAll') || 'Tümünü Kaldır'}
</button> </Button>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{selectedColumns.size}/{selectCommandColumns.length}{' '} {selectedColumns.size}/{selectCommandColumns.length}{' '}
{translate('::App.Listform.ListformField.Column')} {translate('::App.Listform.ListformField.Column')}

View file

@ -430,24 +430,24 @@ const OrganizationUnits = () => {
<div className="file-actions"> <div className="file-actions">
<div className="flex gap-1 folderFileActions"> <div className="flex gap-1 folderFileActions">
<button <Button
onClick={() => setIsMoveAllUsersOpen(true)} onClick={() => setIsMoveAllUsersOpen(true)}
title={translate('::Abp.Identity.OrganizationUnit.MoveAllUsers')} title={translate('::Abp.Identity.OrganizationUnit.MoveAllUsers')}
> >
<FaUserPlus size="20" color="#2d6da3" /> <FaUserPlus size="20" color="#2d6da3" />
</button> </Button>
<button <Button
onClick={() => node.edit()} onClick={() => node.edit()}
title={translate('::Abp.Identity.OrganizationUnit.Rename')} title={translate('::Abp.Identity.OrganizationUnit.Rename')}
> >
<FaEdit size="20" className="text-teal-900" /> <FaEdit size="20" className="text-teal-900" />
</button> </Button>
<button <Button
onClick={() => setDeleteRow({ id: node.data.id, name: 'Organization Unit' })} onClick={() => setDeleteRow({ id: node.data.id, name: 'Organization Unit' })}
title={translate('::Delete')} title={translate('::Delete')}
> >
<FaTrashAlt size="20" className="text-red-500" /> <FaTrashAlt size="20" className="text-red-500" />
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { Button } from '@/components/ui'
export function Forum() { export function Forum() {
const { translate } = useLocalization() const { translate } = useLocalization()
@ -63,9 +64,9 @@ export function Forum() {
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"> <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong className="font-bold">Error: </strong> <strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span> <span className="block sm:inline">{error}</span>
<button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3"> <Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3">
<span className="sr-only">Dismiss</span>× <span className="sr-only">Dismiss</span>×
</button> </Button>
</div> </div>
</div> </div>
)} )}

View file

@ -5,6 +5,7 @@ import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { Button } from '@/components/ui'
export function Management() { export function Management() {
const { const {
@ -69,9 +70,9 @@ export function Management() {
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"> <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong className="font-bold">Error: </strong> <strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span> <span className="block sm:inline">{error}</span>
<button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3"> <Button onClick={clearError} className="absolute top-0 bottom-0 right-0 px-4 py-3">
<span className="sr-only">Dismiss</span>× <span className="sr-only">Dismiss</span>×
</button> </Button>
</div> </div>
</div> </div>
)} )}

View file

@ -6,6 +6,7 @@ import { PostManagement } from './PostManagement'
import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum' import { ForumCategory, ForumPost, ForumTopic } from '@/proxy/forum/forum'
import { AdminStats } from './Dashboard' import { AdminStats } from './Dashboard'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Button } from '@/components/ui'
interface AdminViewProps { interface AdminViewProps {
categories: ForumCategory[] categories: ForumCategory[]
@ -108,7 +109,7 @@ export function AdminView({
{navigationItems.map((item) => { {navigationItems.map((item) => {
const Icon = item.icon const Icon = item.icon
return ( return (
<button <Button
key={item.id} key={item.id}
onClick={() => setActiveSection(item.id)} onClick={() => setActiveSection(item.id)}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-left transition-colors ${ className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg text-left transition-colors ${
@ -119,7 +120,7 @@ export function AdminView({
> >
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span> <span className="font-medium">{item.label}</span>
</button> </Button>
) )
})} })}
</nav> </nav>

View file

@ -165,14 +165,15 @@ export function CategoryManagement({
<h2 className="text-2xl font-bold text-gray-900"> <h2 className="text-2xl font-bold text-gray-900">
{translate('::App.Forum.CategoryManagement.Title')} {translate('::App.Forum.CategoryManagement.Title')}
</h2> </h2>
<button <Button
variant="solid"
onClick={() => setShowCreateForm(true)} onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50" className="flex items-center space-x-2 bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.Forum.CategoryManagement.AddCategory')}</span> <span>{translate('::App.Forum.CategoryManagement.AddCategory')}</span>
</button> </Button>
</div> </div>
{/* Create/Edit Form */} {/* Create/Edit Form */}
@ -367,7 +368,8 @@ export function CategoryManagement({
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <Button
size='xs'
onClick={() => handleToggleActive(category)} onClick={() => handleToggleActive(category)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
category.isActive category.isActive
@ -381,9 +383,10 @@ export function CategoryManagement({
) : ( ) : (
<FaEyeSlash className="w-4 h-4" /> <FaEyeSlash className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
size='xs'
onClick={() => handleToggleLocked(category)} onClick={() => handleToggleLocked(category)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
category.isLocked category.isLocked
@ -397,23 +400,25 @@ export function CategoryManagement({
) : ( ) : (
<FaUnlock className="w-4 h-4" /> <FaUnlock className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
size='xs'
onClick={() => handleEdit(category)} onClick={() => handleEdit(category)}
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors" className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
title={translate('::App.Forum.CategoryManagement.EditCategory')} title={translate('::App.Forum.CategoryManagement.EditCategory')}
> >
<FaEdit className="w-4 h-4" /> <FaEdit className="w-4 h-4" />
</button> </Button>
<button <Button
size='xs'
onClick={() => confirmDeleteCategory(category)} onClick={() => confirmDeleteCategory(category)}
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors" className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
title={translate('::App.Forum.CategoryManagement.DeleteCategory')} title={translate('::App.Forum.CategoryManagement.DeleteCategory')}
> >
<FaTrash className="w-4 h-4" /> <FaTrash className="w-4 h-4" />
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -164,14 +164,15 @@ export function PostManagement({
<h2 className="text-2xl font-bold text-gray-900"> <h2 className="text-2xl font-bold text-gray-900">
{translate('::App.Forum.PostManagement.Title')} {translate('::App.Forum.PostManagement.Title')}
</h2> </h2>
<button <Button
variant="solid"
onClick={() => setShowCreateForm(true)} onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50" className="flex items-center space-x-2 bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.Forum.PostManagement.AddPost')}</span> <span>{translate('::App.Forum.PostManagement.AddPost')}</span>
</button> </Button>
</div> </div>
{/* Create/Edit Form */} {/* Create/Edit Form */}
@ -397,7 +398,7 @@ export function PostManagement({
</div> </div>
<div className="flex items-center space-x-2 ml-4"> <div className="flex items-center space-x-2 ml-4">
<button <Button
onClick={() => handleToggleAcceptedAnswer(post)} onClick={() => handleToggleAcceptedAnswer(post)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
post.isAcceptedAnswer post.isAcceptedAnswer
@ -415,23 +416,23 @@ export function PostManagement({
) : ( ) : (
<FaCircle className="w-4 h-4" /> <FaCircle className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
onClick={() => handleEdit(post)} onClick={() => handleEdit(post)}
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors" className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
title={translate('::App.Forum.PostManagement.EditPost')} title={translate('::App.Forum.PostManagement.EditPost')}
> >
<FaEdit className="w-4 h-4" /> <FaEdit className="w-4 h-4" />
</button> </Button>
<button <Button
onClick={() => confirmDeletePost(post)} onClick={() => confirmDeletePost(post)}
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors" className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
title={translate('::App.Forum.PostManagement.DeletePost')} title={translate('::App.Forum.PostManagement.DeletePost')}
> >
<FaTrashAlt className="w-4 h-4" /> <FaTrashAlt className="w-4 h-4" />
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -206,14 +206,15 @@ export function TopicManagement({
<h2 className="text-2xl font-bold text-gray-900"> <h2 className="text-2xl font-bold text-gray-900">
{translate('::App.Forum.TopicManagement.Title')} {translate('::App.Forum.TopicManagement.Title')}
</h2> </h2>
<button <Button
variant="solid"
onClick={() => setShowCreateForm(true)} onClick={() => setShowCreateForm(true)}
disabled={loading} disabled={loading}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50" className="flex items-center space-x-2 bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.Forum.TopicManagement.AddTopic')}</span> <span>{translate('::App.Forum.TopicManagement.AddTopic')}</span>
</button> </Button>
</div> </div>
{/* Create/Edit Form */} {/* Create/Edit Form */}
@ -398,7 +399,7 @@ export function TopicManagement({
</div> </div>
<div className="flex items-center space-x-2 ml-4"> <div className="flex items-center space-x-2 ml-4">
<button <Button
onClick={() => handlePin(topic)} onClick={() => handlePin(topic)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
topic.isPinned topic.isPinned
@ -412,9 +413,9 @@ export function TopicManagement({
) : ( ) : (
<FaTree className="w-4 h-4" /> <FaTree className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
onClick={() => handleLock(topic)} onClick={() => handleLock(topic)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
topic.isLocked topic.isLocked
@ -428,9 +429,9 @@ export function TopicManagement({
) : ( ) : (
<FaUnlock className="w-4 h-4" /> <FaUnlock className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
onClick={() => handleSolved(topic)} onClick={() => handleSolved(topic)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
topic.isSolved topic.isSolved
@ -444,23 +445,23 @@ export function TopicManagement({
) : ( ) : (
<FaCircle className="w-4 h-4" /> <FaCircle className="w-4 h-4" />
)} )}
</button> </Button>
<button <Button
onClick={() => handleEdit(topic)} onClick={() => handleEdit(topic)}
className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors" className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
title={translate('::App.Forum.TopicManagement.EditTopic')} title={translate('::App.Forum.TopicManagement.EditTopic')}
> >
<FaEdit className="w-4 h-4" /> <FaEdit className="w-4 h-4" />
</button> </Button>
<button <Button
onClick={() => confirmDeleteTopic(topic)} onClick={() => confirmDeleteTopic(topic)}
className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors" className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
title={translate('::App.Forum.TopicManagement.DeleteTopic')} title={translate('::App.Forum.TopicManagement.DeleteTopic')}
> >
<FaTrashAlt className="w-4 h-4" /> <FaTrashAlt className="w-4 h-4" />
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,6 +11,7 @@ import { ForumTopicCard } from './ForumTopicCard'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { forumService } from '@/services/forum.service' import { forumService } from '@/services/forum.service'
import { Button } from '@/components/ui'
interface ForumViewProps { interface ForumViewProps {
categories: ForumCategory[] categories: ForumCategory[]
@ -352,41 +353,45 @@ export function ForumView({
{/* Right Side: Actions + Search */} {/* Right Side: Actions + Search */}
<div className="flex items-center space-x-2 ml-auto"> <div className="flex items-center space-x-2 ml-auto">
{viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && ( {viewState === 'topics' && selectedCategory && !selectedCategory.isLocked && (
<button <Button
variant='solid'
onClick={() => setShowCreateTopic(true)} onClick={() => setShowCreateTopic(true)}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors" className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.Forum.TopicManagement.NewTopic')}</span> <span>{translate('::App.Forum.TopicManagement.NewTopic')}</span>
</button> </Button>
)} )}
{viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && ( {viewState === 'posts' && selectedTopic && !selectedTopic.isLocked && (
<button <Button
variant='solid'
onClick={() => setShowCreatePost(true)} onClick={() => setShowCreatePost(true)}
className="flex items-center space-x-2 bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors" className="flex items-center space-x-2 bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors"
> >
<FaPlus className="w-4 h-4" /> <FaPlus className="w-4 h-4" />
<span>{translate('::App.Forum.PostManagement.NewPost')}</span> <span>{translate('::App.Forum.PostManagement.NewPost')}</span>
</button> </Button>
)} )}
{/* Search */} {/* Search */}
<button <Button
onClick={() => setIsSearchModalOpen(true)} onClick={() => setIsSearchModalOpen(true)}
variant='default'
className="hidden md:flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" className="hidden md:flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<FaSearch className="w-4 h-4 text-gray-400" /> <FaSearch className="w-4 h-4 text-gray-400" />
<span className="text-gray-500"> <span className="text-gray-500">
{translate('::App.Forum.TopicManagement.Searchtopics')} {translate('::App.Forum.TopicManagement.Searchtopics')}
</span> </span>
</button> </Button>
<button <Button
onClick={() => setIsSearchModalOpen(true)} onClick={() => setIsSearchModalOpen(true)}
variant='default'
className="md:hidden p-2 text-gray-400 hover:text-gray-600 transition-colors" className="md:hidden p-2 text-gray-400 hover:text-gray-600 transition-colors"
> >
<FaSearch className="w-5 h-5" /> <FaSearch className="w-5 h-5" />
</button> </Button>
</div> </div>
</div> </div>

View file

@ -143,13 +143,13 @@ const ChartDrawer = ({
<span>📊</span> <span>📊</span>
{translate('::App.Platform.ChartDrawer.ChartSeries')} {translate('::App.Platform.ChartDrawer.ChartSeries')}
</h2> </h2>
<button <Button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-full transition-colors" className="p-2 hover:bg-gray-200 rounded-full transition-colors"
> >
<FaTimes /> <FaTimes />
</button> </Button>
</div> </div>
<FormContainer size="sm" className="flex flex-col flex-1 overflow-hidden"> <FormContainer size="sm" className="flex flex-col flex-1 overflow-hidden">
@ -205,7 +205,7 @@ const ChartDrawer = ({
<Field name={`series[${index}].type`}> <Field name={`series[${index}].type`}>
{({ field, form }: FieldProps) => ( {({ field, form }: FieldProps) => (
<div className="relative"> <div className="relative">
<button <Button
type="button" type="button"
onClick={() => onClick={() =>
setSelectedSeriesIndex( setSelectedSeriesIndex(
@ -222,12 +222,12 @@ const ChartDrawer = ({
{chartSeriesTypeOptions.find((t) => t.label === field.value) {chartSeriesTypeOptions.find((t) => t.label === field.value)
?.label || field.value} ?.label || field.value}
</span> </span>
</button> </Button>
{selectedSeriesIndex === index && ( {selectedSeriesIndex === index && (
<div className="absolute z-50 mt-1 w-full bg-white border rounded-lg shadow-xl p-2"> <div className="absolute z-50 mt-1 w-full bg-white border rounded-lg shadow-xl p-2">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{chartSeriesTypeOptions.map((chartType) => ( {chartSeriesTypeOptions.map((chartType) => (
<button <Button
key={chartType.label} key={chartType.label}
type="button" type="button"
onClick={() => { onClick={() => {
@ -244,7 +244,7 @@ const ChartDrawer = ({
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{chartType.label} {chartType.label}
</span> </span>
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@ import { getAbout, saveAboutPage } from '@/services/about'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import Loading from '@/components/shared/Loading' import Loading from '@/components/shared/Loading'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { Notification, toast } from '@/components/ui' import { Button, Notification, toast } from '@/components/ui'
import { useStoreState } from '@/store' import { useStoreState } from '@/store'
import { useStoreActions } from '@/store' import { useStoreActions } from '@/store'
import DesignerDrawer from './designer/DesignerDrawer' import DesignerDrawer from './designer/DesignerDrawer'
@ -699,13 +699,13 @@ const About: React.FC = () => {
<div className={`min-h-screen bg-gray-50 ${isDesignMode && isPanelVisible ? 'xl:pr-[420px]' : ''}`}> <div className={`min-h-screen bg-gray-50 ${isDesignMode && isPanelVisible ? 'xl:pr-[420px]' : ''}`}>
{isDesignMode && !isPanelVisible && ( {isDesignMode && !isPanelVisible && (
<button <Button
type="button" type="button"
onClick={() => setIsPanelVisible(true)} onClick={() => setIsPanelVisible(true)}
className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl" className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl"
> >
{translate('::Public.designer.showPanel')} {translate('::Public.designer.showPanel')}
</button> </Button>
)} )}
{/* Hero Section */} {/* Hero Section */}

View file

@ -8,6 +8,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { Loading } from '@/components/shared' import { Loading } from '@/components/shared'
import { APP_NAME } from '@/constants/app.constant' import { APP_NAME } from '@/constants/app.constant'
import { Button } from '@/components/ui'
const Blog = () => { const Blog = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
@ -120,7 +121,7 @@ const Blog = () => {
{/* Category Filter */} {/* Category Filter */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<button <Button
onClick={() => handleCategoryChange('')} onClick={() => handleCategoryChange('')}
className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${ className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${
selectedCategory === '' selectedCategory === ''
@ -129,9 +130,9 @@ const Blog = () => {
}`} }`}
> >
{translate('::App.Reports.Search')} {translate('::App.Reports.Search')}
</button> </Button>
{categories.map((category) => ( {categories.map((category) => (
<button <Button
key={category.id} key={category.id}
onClick={() => handleCategoryChange(category.id)} onClick={() => handleCategoryChange(category.id)}
className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${ className={`px-4 py-2 bg-blue-100 text-blue-800 text-sm font-medium rounded-lg transition-colors ${
@ -141,7 +142,7 @@ const Blog = () => {
}`} }`}
> >
{translate('::Public.' + category.name)} ({category.postCount}) {translate('::Public.' + category.name)} ({category.postCount})
</button> </Button>
))} ))}
</div> </div>
</div> </div>
@ -213,16 +214,16 @@ const Blog = () => {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<nav className="flex gap-2"> <nav className="flex gap-2">
<button <Button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
Önceki Önceki
</button> </Button>
{[...Array(totalPages)].map((_, i) => ( {[...Array(totalPages)].map((_, i) => (
<button <Button
key={i + 1} key={i + 1}
onClick={() => setCurrentPage(i + 1)} onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg ${ className={`px-4 py-2 rounded-lg ${
@ -232,16 +233,16 @@ const Blog = () => {
}`} }`}
> >
{i + 1} {i + 1}
</button> </Button>
))} ))}
<button <Button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))} onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
Sonraki Sonraki
</button> </Button>
</nav> </nav>
</div> </div>
)} )}
@ -262,9 +263,9 @@ const Blog = () => {
placeholder={translate('::Abp.Account.EmailAddress')} placeholder={translate('::Abp.Account.EmailAddress')}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<button className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"> <Button className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors">
{translate('::Public.common.subscribe')} {translate('::Public.common.subscribe')}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -20,7 +20,7 @@ import DesignerDrawer from './designer/DesignerDrawer'
import SelectableBlock from './designer/SelectableBlock' import SelectableBlock from './designer/SelectableBlock'
import { DesignerSelection } from './designer/types' import { DesignerSelection } from './designer/types'
import { useDesignerState } from './designer/useDesignerState' import { useDesignerState } from './designer/useDesignerState'
import { Notification, toast } from '@/components/ui' import { Button, Notification, toast } from '@/components/ui'
interface ContactContent { interface ContactContent {
heroTitle: string heroTitle: string
@ -659,13 +659,13 @@ const Contact: React.FC = () => {
)} )}
{isDesignMode && !isPanelVisible && ( {isDesignMode && !isPanelVisible && (
<button <Button
type="button" type="button"
onClick={() => setIsPanelVisible(true)} onClick={() => setIsPanelVisible(true)}
className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl" className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl"
> >
{translate('::Public.designer.showPanel')} {translate('::Public.designer.showPanel')}
</button> </Button>
)} )}
<SelectableBlock <SelectableBlock

View file

@ -15,7 +15,7 @@ import DesignerDrawer from './designer/DesignerDrawer'
import SelectableBlock from './designer/SelectableBlock' import SelectableBlock from './designer/SelectableBlock'
import { DesignerSelection } from './designer/types' import { DesignerSelection } from './designer/types'
import { useDesignerState } from './designer/useDesignerState' import { useDesignerState } from './designer/useDesignerState'
import { Notification, toast } from '@/components/ui' import { Button, Notification, toast } from '@/components/ui'
interface ServiceCardContent { interface ServiceCardContent {
icon: string icon: string
@ -905,13 +905,13 @@ const Services: React.FC = () => {
></Helmet> ></Helmet>
{isDesignMode && !isPanelVisible && ( {isDesignMode && !isPanelVisible && (
<button <Button
type="button" type="button"
onClick={() => setIsPanelVisible(true)} onClick={() => setIsPanelVisible(true)}
className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl" className="fixed right-4 top-1/2 z-40 -translate-y-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-xl"
> >
{translate('::Public.designer.showPanel')} {translate('::Public.designer.showPanel')}
</button> </Button>
)} )}
{/* Hero Section */} {/* Hero Section */}

View file

@ -72,7 +72,7 @@ const DesignerDrawer: React.FC<DesignerDrawerProps> = ({
<div className="mt-4 flex flex-wrap gap-1.5"> <div className="mt-4 flex flex-wrap gap-1.5">
{languages.map((language) => ( {languages.map((language) => (
<button <Button
key={`${language.cultureName}-${language.key}`} key={`${language.cultureName}-${language.key}`}
type="button" type="button"
title={language.displayName} title={language.displayName}
@ -89,7 +89,7 @@ const DesignerDrawer: React.FC<DesignerDrawerProps> = ({
shape="circle" shape="circle"
src={`/img/countries/${language.cultureName}.png`} src={`/img/countries/${language.cultureName}.png`}
/> />
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View file

@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from 'react'
import { APP_NAME } from '@/constants/app.constant'
import { getMigrateUrl } from '@/services/setup.service'
import { applicationConfigurationUrl, getAppConfig } from '@/services/abpConfig.service'
interface LogLine {
level: 'info' | 'warn' | 'error' | 'success' | 'restart' | 'done'
message: string
}
type MigrationStatus = 'idle' | 'running' | 'success' | 'error' | 'restarting'
const levelClass: Record<string, string> = {
info: 'text-gray-300',
warn: 'text-yellow-400',
error: 'text-red-400',
success: 'text-green-400',
restart: 'text-blue-400',
done: 'text-blue-400',
}
const DatabaseSetup = () => {
const [logs, setLogs] = useState<LogLine[]>([])
const [status, setStatus] = useState<MigrationStatus>('idle')
const [pollCountdown, setPollCountdown] = useState(0)
const logEndRef = useRef<HTMLDivElement>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs])
// Cleanup on component unmount
useEffect(() => {
return () => {
eventSourceRef.current?.close()
if (pollTimerRef.current) clearTimeout(pollTimerRef.current)
}
}, [])
const addLog = (level: LogLine['level'], message: string) => {
setLogs((prev) => [...prev, { level, message }])
}
// ── Poll ABP config endpoint until server is ready ──────────────
const pollUntilReady = () => {
setStatus('restarting')
let attempt = 0
let successCount = 0
const REQUIRED_SUCCESS = 2 // sunucunun kararlı olduğunu doğrulamak için arka arkaya 2 başarılı yanıt
const tick = async () => {
attempt++
setPollCountdown(attempt)
try {
const res = await fetch(
`${import.meta.env.VITE_API_URL ?? ''}${applicationConfigurationUrl(false)}`,
{
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
},
)
if (res.status === 200) {
try {
const json = await res.json()
// ABP config yanıtının geçerli olduğunu doğrula (currentUser alanı her zaman bulunur)
if (json && typeof json.currentUser === 'object') {
successCount++
if (successCount >= REQUIRED_SUCCESS) {
// Sunucu tamamen hazır — yönlendir
window.location.href = '/'
return
}
// Bir sonraki doğrulama denemesi
pollTimerRef.current = setTimeout(tick, 1000)
return
}
} catch {
// JSON parse hatası — sunucu henüz tam hazır değil
}
}
// Başarısız — sıfırla ve tekrar dene
successCount = 0
} catch {
// Sunucu henüz yanıt vermiyor — bekleniyor, tekrar dene
successCount = 0
}
pollTimerRef.current = setTimeout(tick, 2000)
}
// İlk denemeden önce kısa bir bekleme (sunucunun kapanma süresi)
pollTimerRef.current = setTimeout(tick, 3000)
}
const startMigration = () => {
if (status === 'running') return
setLogs([])
setStatus('running')
addLog('info', 'Starting migration...')
const url = getMigrateUrl()
const es = new EventSource(url)
eventSourceRef.current = es
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as { level?: string; message?: string }
const level = (data.level ?? 'info') as LogLine['level']
const message = data.message ?? event.data
if (level === 'done') {
es.close()
eventSourceRef.current = null
// If we received a "restart" event, switch to poll mode; otherwise keep success/error state
setStatus((prev) => {
if (prev === 'running') return 'error' // failed — no restart event received
return prev
})
return
}
addLog(level, message)
if (level === 'success') {
setStatus('success')
} else if (level === 'error') {
setStatus('error')
} else if (level === 'restart') {
// Backend is stopping the minimal app — start polling
pollUntilReady()
}
} catch {
addLog('info', event.data)
}
}
es.onerror = () => {
es.close()
eventSourceRef.current = null
setStatus((prev) => (prev === 'running' ? 'error' : prev))
addLog('error', 'Server connection lost or migration could not be completed.')
}
}
return (
<div className="min-h-screen bg-gray-900 flex flex-col items-center justify-center p-6 text-white">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-2">{APP_NAME}</h1>
<p className="text-gray-400 text-lg">Initial Setup Creating Database</p>
</div>
{/* Status Card */}
<div className="w-full max-w-4xl bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
{/* Card Header */}
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-b border-gray-600">
<div className="flex items-center gap-3">
<StatusIndicator status={status} />
<span className="font-semibold text-sm tracking-wide">
{status === 'idle' && 'Ready to Setup'}
{status === 'running' && 'Migration Running...'}
{status === 'success' && 'Migration Completed ✓'}
{status === 'restarting' && `Server Restarting... (attempt ${pollCountdown})`}
{status === 'error' && 'Migration Failed ✗'}
</span>
</div>
<span className="text-xs text-gray-400">{logs.length} log lines</span>
</div>
{/* Log Terminal */}
<div className="h-96 overflow-y-auto bg-gray-950 px-4 py-3 font-mono text-xs leading-relaxed">
{logs.length === 0 ? (
<p className="text-gray-600 mt-2">Logs will appear here when migration starts...</p>
) : (
logs.map((line, i) => (
<div key={i} className={`${levelClass[line.level] ?? 'text-gray-300'} mb-0.5`}>
<span className="text-gray-600 mr-2 select-none">{String(i + 1).padStart(4, '0')}</span>
{line.message}
</div>
))
)}
<div ref={logEndRef} />
</div>
{/* Action Area */}
<div className="flex items-center justify-between px-5 py-4 bg-gray-700 border-t border-gray-600">
<div className="text-xs text-gray-400">
{status === 'idle' && 'Database not found. Press the button to start migration.'}
{status === 'running' && 'Please wait, migration is in progress...'}
{status === 'success' && 'Migration completed. Server is restarting...'}
{status === 'restarting' &&
'Waiting for application server to be ready, you will be redirected automatically...'}
{status === 'error' &&
'Review the errors in the logs, fix the issue, and try again.'}
</div>
<div className="flex gap-3">
{(status === 'idle' || status === 'error') && (
<button
onClick={startMigration}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors"
>
{status === 'error' ? 'Retry' : 'Start Setup'}
</button>
)}
{status === 'restarting' && (
<div className="flex items-center gap-2 text-blue-400 text-sm">
<span className="inline-block w-3 h-3 rounded-full bg-blue-400 animate-ping" />
Waiting...
</div>
)}
</div>
</div>
</div>
<p className="mt-6 text-xs text-gray-600">
This page is only visible when the database does not exist.
</p>
</div>
)
}
// ─── Status Indicator ────────────────────────────────────────────────────────
const StatusIndicator = ({ status }: { status: MigrationStatus }) => {
const cls: Record<MigrationStatus, string> = {
idle: 'bg-gray-500',
running: 'bg-yellow-400 animate-pulse',
success: 'bg-green-500',
restarting: 'bg-blue-400 animate-ping',
error: 'bg-red-500',
}
return <span className={`inline-block w-3 h-3 rounded-full ${cls[status]}`} />
}
export default DatabaseSetup