Wizard File Manager içerisine Menü ve Bağımlılıklar eklendi

This commit is contained in:
Sedat ÖZTÜRK 2026-09-18 10:50:40 +03:00
parent b5f411e684
commit 488a3274e1
6 changed files with 414 additions and 47 deletions

View file

@ -3322,6 +3322,14 @@ Wizard Manager'ın kendi aksiyonları: liste/kart görünümü, arama, düzenlem
silme, DB Migrate, Export (zip), Import (diff + rollback). Yetkiler
`App.Listforms.Wizard{,.Create,.Update,.Delete,.Export,.Import}`.
Listede her ekranın üst menü zinciri (`WizardFileInfoDto.MenuPath`, Menu tablosundan; kayıt yoksa
wizard'ın `MenuParentCode` cevabı) görünür. Bağlı wizard'lar üst ekranın altında gruplanır
(`Dependencies`): List yolunda alt formlar, Custom yolunda component `Dependencies` zinciri —
export kapanışıyla aynı kural; wizard'ı olmayan ara component atlanır. Kök, hiçbir wizard'ın
bağımlılığı olmayan dosyadır; ortak bir alt form her üst ekranın altında tekrar görünür.
Gruplar `+`/`−` ile açılıp kapanır (varsayılan açık); arama sırasında eşleşme gizli kalmasın diye
hepsi açık gösterilir.
### 9.9 Numaratör — `Sequence` (`App.Definitions.Sequence`)
"Sipariş numarası otomatik gelsin", "talep numarası her yıl 1'den başlasın" istekleri için kod

View file

@ -381,4 +381,16 @@ public class WizardFileInfoDto
/// <summary>Menu kaydina yazilan URL; Custom yolunda bilesenin rota yoludur.</summary>
public string MenuUrl { get; set; }
/// <summary>
/// Ekranin menu agacindaki ust menuleri, kokten baslayarak; her oge menunun dil anahtaridir.
/// Menusuz wizard'da bostur.
/// </summary>
public List<string> MenuPath { get; set; } = [];
/// <summary>
/// Bu wizard'in dogrudan bagli oldugu diger wizard dosyalari: List yolunda alt formlar, Custom
/// yolunda component bagimliliklari. Yalnizca kapsamda wizard dosyasi olanlar listelenir.
/// </summary>
public List<string> Dependencies { get; set; } = [];
}

View file

@ -593,6 +593,12 @@ public class ListFormWizardAppService(
if (!Directory.Exists(outputPath))
return result;
var seeds = new Dictionary<string, WizardSeedFileDto>(StringComparer.OrdinalIgnoreCase);
var menus = (await repoMenu.GetListAsync())
.Where(m => !string.IsNullOrWhiteSpace(m.Code))
.GroupBy(m => m.Code, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName))
{
var fileName = Path.GetFileName(file);
@ -600,6 +606,9 @@ public class ListFormWizardAppService(
{
var json = await File.ReadAllTextAsync(file);
var seed = WizardSeedFileDto.FromSeedJson(json);
if (seed?.Wizard != null)
seeds[fileName] = seed;
result.Add(new WizardFileInfoDto
{
FileName = fileName,
@ -611,6 +620,7 @@ public class ListFormWizardAppService(
CustomComponentName = seed?.Wizard?.CustomComponentName ?? string.Empty,
MenuUrl = seed?.Wizard?.MenuUrl ?? string.Empty,
CreatedAt = seed?.Wizard?.SeededAt.ToString("s") ?? string.Empty,
MenuPath = seed?.Wizard != null ? ResolveMenuPath(seed.Wizard, menus) : [],
HasInsertedRecords = seed?.InsertedRecords != null &&
(seed.InsertedRecords.LanguageKeys.Count > 0 ||
seed.InsertedRecords.PermissionGroupNames.Count > 0 ||
@ -626,12 +636,138 @@ public class ListFormWizardAppService(
}
}
await FillWizardDependenciesAsync(result, seeds);
// CreatedAt sortable ("s") bicimindedir; metin karsilastirmasi da kronolojiktir.
return [.. result
.OrderByDescending(x => x.CreatedAt, StringComparer.Ordinal)
.ThenBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)];
}
/// <summary>
/// Ekranin ust menu zinciri, kokten baslayarak. Menu tablosu esastir — ekran sonradan baska bir
/// menuye tasinmis olabilir; menu kaydi hic yoksa wizard'in ust menu cevabina duser.
/// </summary>
private static List<string> ResolveMenuPath(ListFormWizardDto wizard, Dictionary<string, Menu> menus)
{
if (!wizard.CreateMenu)
return [];
if (!menus.TryGetValue(wizard.ResolveMenuCode(), out var menu))
{
return string.IsNullOrWhiteSpace(wizard.MenuParentCode) ? [] : [wizard.MenuParentCode.Trim()];
}
var path = new List<string>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { menu.Code };
// Bozuk bir ParentCode dongusu listeyi kilitlemesin diye ziyaret edilenler takip edilir.
while (!string.IsNullOrWhiteSpace(menu.ParentCode)
&& visited.Add(menu.ParentCode)
&& menus.TryGetValue(menu.ParentCode, out var parent))
{
path.Insert(0, string.IsNullOrWhiteSpace(parent.DisplayName) ? parent.Code : parent.DisplayName);
menu = parent;
}
return path;
}
/// <summary>
/// Her wizard'in dogrudan bagli oldugu diger wizard dosyalarini doldurur. Bagimlilik kurallari
/// export kapanisiyla aynidir (<see cref="WizardExportCollector"/>): List yolunda alt formlar,
/// Custom yolunda component zinciri. Custom zincirinde wizard'i olmayan ara component'ler
/// atlanarak altlarindaki wizard'lara ulasilir; aksi halde grup ara halkada kopardi.
/// </summary>
private async Task FillWizardDependenciesAsync(List<WizardFileInfoDto> files, Dictionary<string, WizardSeedFileDto> seeds)
{
var fileByListFormCode = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var fileByComponent = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (fileName, seed) in seeds)
{
if (seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom)
{
if (!string.IsNullOrWhiteSpace(seed.Wizard.CustomComponentName))
fileByComponent.TryAdd(seed.Wizard.CustomComponentName.Trim(), fileName);
}
else
{
fileByListFormCode.TryAdd(seed.Wizard.ResolveListFormCode(), fileName);
}
}
var componentDependencies = fileByComponent.Count > 0
? await new WizardExportCollector(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id))
.ReadCustomComponentDependenciesAsync()
: [];
foreach (var file in files)
{
if (!seeds.TryGetValue(file.FileName, out var seed))
continue;
var dependencies = seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom
? ResolveComponentDependencyFiles(seed.Wizard.CustomComponentName, componentDependencies, fileByComponent)
: ReadSubFormCodes(seed)
.Select(code => fileByListFormCode.GetValueOrDefault(code))
.OfType<string>();
file.Dependencies = [.. dependencies
.Where(d => !string.Equals(d, file.FileName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)];
}
}
private static IEnumerable<string> ReadSubFormCodes(WizardSeedFileDto seed)
{
SubFormDto[] subForms;
try
{
subForms = seed.ListForm?.SubFormsDto ?? [.. seed.Wizard.SubForms ?? []];
}
catch (JsonException)
{
return [];
}
return subForms
.Select(s => s?.Code?.Trim())
.Where(code => !string.IsNullOrWhiteSpace(code))
.OfType<string>();
}
private static List<string> ResolveComponentDependencyFiles(
string componentName,
Dictionary<string, List<string>> componentDependencies,
Dictionary<string, string> fileByComponent)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(componentName))
return result;
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { componentName.Trim() };
var pending = new Queue<string>(componentDependencies.GetValueOrDefault(componentName.Trim()) ?? []);
while (pending.Count > 0)
{
var name = pending.Dequeue();
if (!visited.Add(name))
continue;
if (fileByComponent.TryGetValue(name, out var fileName))
{
result.Add(fileName);
continue;
}
foreach (var next in componentDependencies.GetValueOrDefault(name) ?? [])
pending.Enqueue(next);
}
return result;
}
public async Task<WizardSeedFileDto> GetFile(string fileName)
{
var filePath = ResolveWizardSeedFilePath(fileName);

View file

@ -262,6 +262,25 @@ public sealed class WizardExportCollector(string scopeRoot)
items[relativePath] = new WizardExportItem(relativePath, "Custom component tanımı", null, content);
}
/// <summary>
/// Component adi -> dogrudan bagimliliklari. Wizard listesi gruplamayi export'un izledigi
/// zincirle ayni kaynaktan kurar; boylece listede birlikte gorunenler arsive de birlikte girer.
/// </summary>
public async Task<Dictionary<string, List<string>>> ReadCustomComponentDependenciesAsync()
{
var rows = await ReadCustomComponentRowsAsync();
if (rows == null)
return new(StringComparer.OrdinalIgnoreCase);
return rows.ToDictionary(
x => x.Key,
x => ParseSeedRowList<string>(GetRowText(x.Value, "Dependencies"))
.Where(d => !string.IsNullOrWhiteSpace(d))
.Select(d => d.Trim())
.ToList(),
StringComparer.OrdinalIgnoreCase);
}
private async Task<Dictionary<string, Dictionary<string, object>>?> ReadCustomComponentRowsAsync()
{
var seedFile = await ReadCustomComponentFileAsync();

View file

@ -135,6 +135,10 @@ export interface WizardFileInfoDto {
componentKind: WizardComponentKindEnum
customComponentName: string
menuUrl: string
/** Üst menü zinciri (kökten), menülerin dil anahtarları. */
menuPath: string[]
/** Doğrudan bağlı diğer wizard dosyaları: alt formlar / component bağımlılıkları. */
dependencies: string[]
}
export interface WizardInsertedRecordsDto {

View file

@ -22,6 +22,10 @@ import {
FaFileImport,
FaTh,
FaList,
FaSitemap,
FaLevelUpAlt,
FaPlusSquare,
FaMinusSquare,
} from 'react-icons/fa'
import DbMigrateButton from '@/components/shared/DbMigrateButton'
import {
@ -55,6 +59,77 @@ interface ConfirmState {
wizardName: string
}
interface WizardTreeNode {
file: WizardFileInfoDto
children: WizardTreeNode[]
}
/**
* Bağlı wizard'ları üst ekranın altında toplar. Kök, hiçbir wizard'ın bağımlılığı olmayan
* dosyadır; birden fazla ekranın kullandığı bir alt form her birinin altında görünür. Döngüdeki
* dosyaların kökü olmadığı için hiç çizilmeyenler sonda kök olarak eklenir.
*/
const buildWizardTree = (files: WizardFileInfoDto[]): WizardTreeNode[] => {
const byName = new Map(files.map((f) => [f.fileName.toLowerCase(), f]))
const childNames = new Set(
files.flatMap((f) => (f.dependencies ?? []).map((d) => d.toLowerCase())),
)
const rendered = new Set<string>()
const build = (file: WizardFileInfoDto, path: Set<string>): WizardTreeNode => {
const key = file.fileName.toLowerCase()
rendered.add(key)
const nextPath = new Set(path).add(key)
const children = (file.dependencies ?? [])
.map((d) => byName.get(d.toLowerCase()))
.filter((c): c is WizardFileInfoDto => !!c && !nextPath.has(c.fileName.toLowerCase()))
.map((c) => build(c, nextPath))
return { file, children }
}
const roots = files
.filter((f) => !childNames.has(f.fileName.toLowerCase()))
.map((f) => build(f, new Set()))
// Döngünün bir üyesi çizildiğinde diğerleri de çizilir; kontrol her adımda tekrarlanır.
for (const f of files) {
if (!rendered.has(f.fileName.toLowerCase())) roots.push(build(f, new Set()))
}
return roots
}
/** Aramada bir düğüm, kendisi ya da altındakilerden biri eşleşiyorsa kalır; grup bağlamı korunur. */
const filterWizardTree = (
nodes: WizardTreeNode[],
matches: (f: WizardFileInfoDto) => boolean,
): WizardTreeNode[] =>
nodes.flatMap((node) => {
const children = filterWizardTree(node.children, matches)
return matches(node.file) || children.length > 0 ? [{ ...node, children }] : []
})
interface WizardTreeRow {
file: WizardFileInfoDto
depth: number
/** Kökten gelen yol; ortak alt form her üst ekranın altında ayrı açılıp kapanabilsin diye. */
key: string
hasChildren: boolean
}
/** Kapalı grupların altı satıra dökülmez. */
const flattenWizardTree = (
nodes: WizardTreeNode[],
collapsed: Set<string>,
depth = 0,
prefix = '',
): WizardTreeRow[] =>
nodes.flatMap((node) => {
const key = `${prefix}${node.file.fileName}`
const row = { file: node.file, depth, key, hasChildren: node.children.length > 0 }
return collapsed.has(key)
? [row]
: [row, ...flattenWizardTree(node.children, collapsed, depth + 1, `${key}/`)]
})
const WizardFileManager = () => {
const navigate = useNavigate()
const { translate } = useLocalization()
@ -91,15 +166,42 @@ const WizardFileManager = () => {
const canExport = checkPermission(WIZARD_PERMISSION.EXPORT)
const canImport = checkPermission(WIZARD_PERMISSION.IMPORT)
const filteredFiles = useMemo(() => {
const menuPathText = useCallback(
(f: WizardFileInfoDto) => (f.menuPath ?? []).map((key) => translate('::' + key)).join(' › '),
[translate],
)
const tree = useMemo(() => {
const nodes = buildWizardTree(files)
const q = search.trim().toLowerCase()
if (!q) return files
return files.filter(
if (!q) return nodes
return filterWizardTree(
nodes,
(f) =>
(f.wizardName ?? '').toLowerCase().includes(q) ||
(f.listFormCode ?? '').toLowerCase().includes(q),
(f.listFormCode ?? '').toLowerCase().includes(q) ||
(f.customComponentName ?? '').toLowerCase().includes(q) ||
menuPathText(f).toLowerCase().includes(q),
)
}, [files, search, menuPathText])
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
// Arama sırasında eşleşme kapalı bir grubun içinde kalmasın diye gruplar açık gösterilir.
const effectiveCollapsed = useMemo(
() => (search.trim() ? new Set<string>() : collapsed),
[collapsed, search],
)
const toggleGroup = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev)
if (!next.delete(key)) next.add(key)
return next
})
const tableRows = useMemo(
() => flattenWizardTree(tree, effectiveCollapsed),
[tree, effectiveCollapsed],
)
}, [files, search])
const loadFiles = useCallback(async () => {
setLoading(true)
@ -281,6 +383,67 @@ const WizardFileManager = () => {
</div>
)
const renderKindBadge = (f: WizardFileInfoDto) => {
const isCustom = f.componentKind === WizardComponentKindEnum.Custom
return (
<span
className={classNames(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap',
isCustom
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
)}
>
{isCustom ? <FaCube /> : <FaListUl />}
{translate(
isCustom ? '::App.WizardStepComponent.CustomComponent' : '::App.Listforms.Listform',
)}
</span>
)
}
const renderMenuPath = (f: WizardFileInfoDto, className?: string) => {
const text = menuPathText(f)
if (!text) return null
return (
<span
className={classNames('inline-flex min-w-0 max-w-full items-center gap-1', className)}
title={`${translate('::App.Platform.MenuParent')}: ${text}`}
>
<FaSitemap className="shrink-0 text-gray-400" />
<span className="truncate">{text}</span>
</span>
)
}
const componentName = (f: WizardFileInfoDto) =>
f.componentKind === WizardComponentKindEnum.Custom ? f.customComponentName : f.listFormCode
/** Alt satırlardaki bağlantı işareti; derinlik kadar içeri itilir. */
const renderDepthMarker = (depth: number) =>
depth > 0 && (
<FaLevelUpAlt
className="shrink-0 rotate-90 text-gray-400"
style={{ marginLeft: (depth - 1) * 16 }}
/>
)
/** Alt ekranı olan satırın aç/kapa düğmesi; olmayanlarda hizayı koruyan boşluk. */
const renderGroupToggle = (row: Pick<WizardTreeRow, 'key' | 'hasChildren'>) => {
if (!row.hasChildren) return <span className="w-3.5 shrink-0" />
const isCollapsed = effectiveCollapsed.has(row.key)
return (
<button
type="button"
className="shrink-0 text-gray-400 hover:text-indigo-500"
title={translate(isCollapsed ? '::App.Platform.ExpandAll' : '::App.Platform.CollapseAll')}
onClick={() => toggleGroup(row.key)}
>
{isCollapsed ? <FaPlusSquare /> : <FaMinusSquare />}
</button>
)
}
return (
<Container>
<PageTitle title={translate('::App.Listforms.WizardManager')} />
@ -384,7 +547,7 @@ const WizardFileManager = () => {
loading={loading}
className={classNames('mt-4', loading && 'min-h-[240px]')}
>
{filteredFiles.length === 0 && !loading && (
{tree.length === 0 && !loading && (
<div className="text-center py-12">
<FaCalendarAlt size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-sm text-gray-400 text-center py-4">
@ -395,9 +558,15 @@ const WizardFileManager = () => {
{viewMode === 'card' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredFiles.map((f) => {
{tree.map((node) => {
const f = node.file
const Icon = navigationIcon[f.menuIcon] ?? FaDatabase
const isCustom = f.componentKind === WizardComponentKindEnum.Custom
const prefix = `${f.fileName}/`
const dependencyCount = flattenWizardTree(node.children, new Set(), 1, prefix).length
const children = effectiveCollapsed.has(f.fileName)
? []
: flattenWizardTree(node.children, effectiveCollapsed, 1, prefix)
return (
<div
key={f.fileName}
@ -419,28 +588,15 @@ const WizardFileManager = () => {
{f.fileName}
</div>
{/* 3. satır: yol ayrımı (Custom / List), layout ve menü adresi */}
{/* 3. satır: yol ayrımı (Custom / List) ve bileşen kodu */}
<div className="flex flex-wrap pt-1 items-center gap-1.5 text-xs">
<span
className={classNames(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium',
isCustom
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
)}
>
{isCustom ? <FaCube /> : <FaListUl />}
{translate(
isCustom
? '::App.WizardStepComponent.CustomComponent'
: '::App.Listforms.Listform',
)}
</span>
<div className="text-xs text-gray-400 truncate">
{isCustom ? f.customComponentName : f.listFormCode}
</div>
{renderKindBadge(f)}
<div className="text-xs text-gray-400 truncate">{componentName(f)}</div>
</div>
{/* 4. satır: ekranın bulunduğu menü */}
{renderMenuPath(f, 'pt-1 text-xs text-gray-600 dark:text-gray-300')}
<div className="flex flex-wrap pt-1 items-center gap-1.5 text-xs">
{/* Custom yolunda layout kavramı yoktur. */}
{!isCustom && f.defaultLayout && (
@ -466,6 +622,40 @@ const WizardFileManager = () => {
f,
'flex items-center justify-end gap-1 pt-1 border-t border-gray-200 dark:border-gray-700',
)}
{/* Bağlı wizard'lar (alt formlar / component bağımlılıkları) aynı kartta kalır. */}
{dependencyCount > 0 && (
<div className="space-y-1 border-t border-gray-200 pt-2 dark:border-gray-700">
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-500">
{renderGroupToggle({ key: f.fileName, hasChildren: true })}
{translate('::App.Platform.Dependencies')} ({dependencyCount})
</div>
{children.map((row) => {
const { file: child, depth, key } = row
const ChildIcon = navigationIcon[child.menuIcon] ?? FaDatabase
return (
<div
key={key}
className="flex items-center gap-1.5 rounded border border-gray-200 bg-white px-2 py-1 dark:border-gray-700 dark:bg-gray-900"
>
{renderDepthMarker(depth)}
{renderGroupToggle(row)}
<ChildIcon className="shrink-0 text-indigo-400" />
<div className="min-w-0 flex-1">
<div
className="truncate text-xs font-medium text-gray-800 dark:text-gray-200"
title={child.wizardName || child.fileName}
>
{child.wizardName || child.fileName}
</div>
{renderMenuPath(child, 'text-[11px] text-gray-500')}
</div>
{renderActions(child, 'flex shrink-0 items-center gap-0.5')}
</div>
)
})}
</div>
)}
</div>
)
})}
@ -487,6 +677,9 @@ const WizardFileManager = () => {
<th className="text-left font-medium px-4 py-2 whitespace-nowrap">
{translate('::App.WizardStepComponent.ComponentType')}
</th>
<th className="text-left font-medium px-4 py-2 whitespace-nowrap">
{translate('::App.Platform.MenuParent')}
</th>
<th className="text-left font-medium px-4 py-2">
{translate('::App.WizardStepComponent.MenuUrl')}
</th>
@ -496,16 +689,24 @@ const WizardFileManager = () => {
</tr>
</thead>
<tbody>
{filteredFiles.map((f) => {
{tableRows.map((row) => {
const { file: f, depth, key } = row
const Icon = navigationIcon[f.menuIcon] ?? FaDatabase
const isCustom = f.componentKind === WizardComponentKindEnum.Custom
return (
<tr
key={f.fileName}
className="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
key={key}
className={classNames(
'hover:bg-gray-50 dark:hover:bg-gray-800',
// Grup başı çizgiyle ayrılır; alt satırlar üst ekranına yapışık kalır.
depth === 0
? 'border-t border-gray-200 dark:border-gray-700'
: 'bg-gray-50/50 dark:bg-gray-800/40',
)}
>
<td className="px-4 py-2">
<div className="flex items-center gap-2 min-w-0">
{renderDepthMarker(depth)}
{renderGroupToggle(row)}
<Icon className="text-indigo-400 shrink-0 text-lg" />
<span
className="font-medium text-gray-800 dark:text-gray-200 truncate"
@ -522,24 +723,11 @@ const WizardFileManager = () => {
{f.fileName}
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-300">
{isCustom ? f.customComponentName : f.listFormCode}
{componentName(f)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<span
className={classNames(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
isCustom
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
)}
>
{isCustom ? <FaCube /> : <FaListUl />}
{translate(
isCustom
? '::App.WizardStepComponent.CustomComponent'
: '::App.Listforms.Listform',
)}
</span>
<td className="px-4 py-2 whitespace-nowrap">{renderKindBadge(f)}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-300 whitespace-nowrap">
{renderMenuPath(f)}
</td>
<td
className="px-4 py-2 font-mono text-gray-600 dark:text-gray-300"