Skip to content

Commit 66e4adf

Browse files
Merge pull request #11 from designbycode/theme-uploader
add theme installer
2 parents 6b29fcd + 4611302 commit 66e4adf

14 files changed

Lines changed: 280 additions & 492 deletions

File tree

app/Concerns/HasTheme.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ trait HasTheme
4141
public static function fromCss(
4242
string $css,
4343
string $name,
44-
string $type = 'registry:app'
44+
string $type = 'registry:theme'
4545
): static {
4646
static::assertValidType($type);
4747

@@ -82,7 +82,7 @@ public static function fromRegistry(array $data): static
8282
$instance = new static;
8383

8484
$instance->name = $data['name'];
85-
$instance->type = 'registry:app';
85+
$instance->type = $data['type'] ?? 'registry:theme';
8686
$instance->title = $data['title'] ?? null;
8787
$instance->description = $data['description'] ?? null;
8888
$instance->author = $data['author'] ?? null;
@@ -141,7 +141,7 @@ public function toRegistry(): array
141141
$registry = [
142142
'$schema' => 'https://ui.shadcn.com/schema/registry-item.json',
143143
'name' => $this->name,
144-
'type' => 'registry:app',
144+
'type' => 'registry:theme',
145145
'title' => $this->title,
146146
'description' => $this->description,
147147
'author' => $this->author,
@@ -273,9 +273,9 @@ public function toCss(): string
273273

274274
public static function assertValidType(string $type): void
275275
{
276-
if (! in_array($type, ['registry:app'], true)) {
276+
if (! in_array($type, ['registry:theme'], true)) {
277277
throw new InvalidArgumentException(
278-
"Invalid registry type \"{$type}\". Allowed: registry:app"
278+
"Invalid registry type \"{$type}\". Allowed: registry:theme"
279279
);
280280
}
281281
}
@@ -342,7 +342,7 @@ public function isStyle(): bool
342342

343343
public function isTheme(): bool
344344
{
345-
return $this->type === 'registry:app';
345+
return $this->type === 'registry:theme';
346346
}
347347

348348
public function isComponent(): bool

app/Http/Controllers/RegistriesController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public function show(string $type, string $name): JsonResponse
1515
$model = match ($type) {
1616
'fonts' => Font::class,
1717
'animate-css' => Animate::class,
18-
'themes', 'app' => Theme::class,
18+
'themes', 'theme' => Theme::class,
1919
default => Registry::class,
2020
};
2121

app/Http/Controllers/ThemesController.php

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Http\Request;
77
use Illuminate\Support\Facades\Cache;
88
use Illuminate\Support\Facades\Http;
9+
use Illuminate\Support\Str;
910
use Inertia\Inertia;
1011

1112
class ThemesController extends Controller
@@ -29,11 +30,88 @@ public function store(Request $request)
2930

3031
$data = $response->json();
3132

32-
if (empty($data) || ! isset($data['name'])) {
33+
if (empty($data) || ! is_array($data)) {
3334
return back()->withErrors(['url' => 'Invalid registry JSON format.']);
3435
}
3536

36-
// Check if theme already exists
37+
$errors = [];
38+
39+
if (! is_string($data['name'] ?? null) || $data['name'] === '') {
40+
$errors[] = 'The registry must contain a non-empty "name" field.';
41+
} elseif (! preg_match('/^[a-z0-9\-]+$/i', $data['name'])) {
42+
$errors[] = 'The theme name must only contain letters, numbers, and hyphens.';
43+
}
44+
45+
if (isset($data['cssVars'])) {
46+
if (! is_array($data['cssVars'])) {
47+
$errors[] = '"cssVars" must be an object.';
48+
} else {
49+
if (isset($data['cssVars']['light']) && ! is_array($data['cssVars']['light'])) {
50+
$errors[] = '"cssVars.light" must be an object.';
51+
}
52+
53+
if (isset($data['cssVars']['dark']) && ! is_array($data['cssVars']['dark'])) {
54+
$errors[] = '"cssVars.dark" must be an object.';
55+
}
56+
}
57+
}
58+
59+
if (isset($data['files'])) {
60+
if (! is_array($data['files'])) {
61+
$errors[] = '"files" must be an array.';
62+
} else {
63+
$fileErrors = Theme::validateFiles($data['files']);
64+
foreach ($fileErrors as $error) {
65+
$errors[] = $error;
66+
}
67+
}
68+
}
69+
70+
if (isset($data['font'])) {
71+
if (! is_array($data['font'])) {
72+
$errors[] = '"font" must be an object.';
73+
} else {
74+
foreach (['family', 'provider', 'import', 'variable', 'selector', 'dependency'] as $field) {
75+
if (isset($data['font'][$field]) && ! is_string($data['font'][$field])) {
76+
$errors[] = "\"font.{$field}\" must be a string.";
77+
}
78+
}
79+
80+
foreach (['weight', 'subsets'] as $field) {
81+
if (isset($data['font'][$field]) && ! is_array($data['font'][$field])) {
82+
$errors[] = "\"font.{$field}\" must be an array.";
83+
}
84+
}
85+
}
86+
}
87+
88+
if (isset($data['categories'])) {
89+
if (! is_array($data['categories'])) {
90+
$errors[] = '"categories" must be an array.';
91+
} else {
92+
foreach ($data['categories'] as $i => $category) {
93+
if (! is_string($category)) {
94+
$errors[] = "\"categories.{$i}\" must be a string.";
95+
}
96+
}
97+
}
98+
}
99+
100+
if (! empty($errors)) {
101+
return back()->withErrors(['url' => implode(' ', $errors)]);
102+
}
103+
104+
$data['name'] = Str::kebab($data['name']);
105+
$data['type'] = 'registry:theme';
106+
107+
if (! ($data['author'] ?? null)) {
108+
$host = Str::of(parse_url($request->url, PHP_URL_HOST))
109+
->replaceFirst('www.', '')
110+
->before('.')
111+
->toString();
112+
$data['author'] = $host;
113+
}
114+
37115
if (Theme::where('name', $data['name'])->exists()) {
38116
return back()->withErrors(['url' => "A theme named [{$data['name']}] already exists."]);
39117
}

app/Models/Theme.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
namespace App\Models;
44

55
use App\Concerns\HasTheme;
6+
use App\Observers\ThemeObserver;
67
use Illuminate\Database\Eloquent\Attributes\Fillable;
8+
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
79
use Illuminate\Database\Eloquent\Model;
810
use Illuminate\Database\Eloquent\Relations\BelongsTo;
911
use Illuminate\Database\Eloquent\SoftDeletes;
1012

1113
#[Fillable([
12-
'name', 'title', 'description', 'author',
14+
'name', 'type', 'title', 'description', 'author',
1315
'dependencies', 'devDependencies', 'registryDependencies',
1416
'files',
1517
'css', 'css_base', 'tailwind',
@@ -21,6 +23,7 @@
2123
'extends',
2224
'style', 'icon_library', 'base_color', 'theme',
2325
])]
26+
#[ObservedBy(ThemeObserver::class)]
2427
class Theme extends Model
2528
{
2629
use HasTheme, SoftDeletes;

app/Observers/ThemeObserver.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Observers;
4+
5+
use App\Models\Theme;
6+
use Illuminate\Support\Str;
7+
8+
class ThemeObserver
9+
{
10+
public function creating(Theme $theme): void
11+
{
12+
if (! $theme->title) {
13+
$theme->title = Str::headline($theme->name);
14+
}
15+
}
16+
}

database/migrations/2026_05_08_174618_create_themes_table.php

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ public function up(): void
1717
$table->string('name')->unique();
1818
$table->string('title')->nullable();
1919
$table->text('description')->nullable();
20-
$table->string('author')->nullable();
21-
20+
$table->string('author')->default('designbycode')->nullable();
21+
$table->string('type')->nullable()->default('registry:theme');
2222
$table->json('dependencies')->nullable();
2323
$table->json('devDependencies')->nullable();
2424
$table->json('registryDependencies')->nullable();
@@ -50,43 +50,14 @@ public function up(): void
5050
$table->string('style')->nullable();
5151
$table->string('icon_library')->nullable();
5252
$table->string('base_color')->nullable();
53-
$table->json('app')->nullable();
53+
$table->json('theme')->nullable();
5454

5555
$table->timestamps();
5656
$table->softDeletes();
5757

5858
$table->index('created_at');
5959
});
6060

61-
DB::statement('
62-
INSERT INTO themes (
63-
name, title, description, author,
64-
dependencies, devDependencies, registryDependencies,
65-
files, css, css_base,
66-
vars_theme, vars_light, vars_dark,
67-
font_family, font_mono, font_serif,
68-
font_provider, font_import, font_variable,
69-
font_weight, font_subsets, font_selector, font_dependency,
70-
tailwind, meta, docs, categories,
71-
extends, style, icon_library, base_color, app,
72-
created_at, updated_at
73-
)
74-
SELECT
75-
name, title, description, author,
76-
dependencies, devDependencies, registryDependencies,
77-
files, css, css_base,
78-
vars_theme, vars_light, vars_dark,
79-
font_family, font_mono, font_serif,
80-
font_provider, font_import, font_variable,
81-
font_weight, font_subsets, font_selector, font_dependency,
82-
tailwind, meta, docs, categories,
83-
extends, style, icon_library, base_color, app,
84-
created_at, updated_at
85-
FROM registries
86-
WHERE type = \'registry:app\'
87-
');
88-
89-
DB::table('registries')->where('type', 'registry:app')->delete();
9061
}
9162

9263
public function down(): void

resources/js/pages/themes/create.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export default function ThemeCreate() {
3434
<Heading
3535
title="Create New Theme"
3636
description="Import a shadcn/ui theme registry JSON to create a new theme in the database."
37-
className="mb-8"
3837
/>
3938

4039
<Card>
@@ -48,7 +47,9 @@ export default function ThemeCreate() {
4847
</CardHeader>
4948
<CardContent className="space-y-4">
5049
<div className="space-y-2">
51-
<Label htmlFor="url">Registry URL</Label>
50+
<Label className={`inline-flex`} htmlFor="url">
51+
Registry URL
52+
</Label>
5253
<Input
5354
id="url"
5455
type="url"
@@ -67,7 +68,7 @@ export default function ThemeCreate() {
6768
)}
6869
</div>
6970
</CardContent>
70-
<CardFooter>
71+
<CardFooter className={`pt-4`}>
7172
<Button type="submit" disabled={processing}>
7273
{processing && (
7374
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -79,7 +80,9 @@ export default function ThemeCreate() {
7980
</Card>
8081

8182
<div className="mt-8 rounded-lg bg-muted p-4">
82-
<h3 className="mb-2 text-sm font-semibold">Example URLs:</h3>
83+
<h3 className="mb-2 text-sm font-semibold">
84+
Example URLs:
85+
</h3>
8386
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
8487
<li>https://tweakcn.com/r/themes/neo-brutalism.json</li>
8588
<li>https://tweakcn.com/r/themes/modern-dark.json</li>

resources/js/pages/themes/show.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import MainEditorBlock from '@/layouts/main/theme/main-editor-block';
2828

2929
import MainLayout from '@/layouts/main-layout';
3030
import type { Registry } from '@/types/registry';
31+
import ThemeLayout from '@/layouts/theme-layout';
3132

3233
interface ThemesShowProps {
3334
theme: Registry;
@@ -256,10 +257,7 @@ function ThemesShow({ theme }: ThemesShowProps) {
256257
</div>
257258
<div className="flex flex-wrap items-center gap-2">
258259
{theme.author && (
259-
<Badge
260-
variant="outline"
261-
className="text-[10px]"
262-
>
260+
<Badge variant="outline" className="text-sm">
263261
by {theme.author}
264262
</Badge>
265263
)}
@@ -310,7 +308,7 @@ function ThemesShow({ theme }: ThemesShowProps) {
310308
className="space-y-12 outline-none"
311309
>
312310
<div className={appearance === 'dark' ? 'dark' : ''}>
313-
<div className="rounded-xl transition-colors duration-300">
311+
<div className="-mx-6 rounded-xl bg-black/5 p-6 transition-colors duration-300 dark:bg-white/5">
314312
<section className="space-y-12">
315313
<div>
316314
<h2 className="text-2xl font-bold tracking-tight">
@@ -626,6 +624,6 @@ ${Object.entries(theme.vars_dark || {})
626624
);
627625
}
628626

629-
ThemesShow.layout = MainLayout;
627+
ThemesShow.layout = ThemeLayout;
630628

631629
export default ThemesShow;

0 commit comments

Comments
 (0)