studio-engine 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/Gemfile +18 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/app/controllers/concerns/studio/error_handling.rb +149 -0
- data/app/controllers/error_logs_controller.rb +16 -0
- data/app/controllers/navbar_controller.rb +6 -0
- data/app/controllers/omniauth_callbacks_controller.rb +17 -0
- data/app/controllers/registrations_controller.rb +25 -0
- data/app/controllers/schema_controller.rb +14 -0
- data/app/controllers/sessions_controller.rb +65 -0
- data/app/controllers/theme_settings_controller.rb +35 -0
- data/app/helpers/studio_theme_helper.rb +15 -0
- data/app/jobs/error_log_cleanup_job.rb +8 -0
- data/app/models/concerns/sluggable.rb +17 -0
- data/app/models/error_log.rb +45 -0
- data/app/models/image_cache.rb +11 -0
- data/app/models/theme_setting.rb +30 -0
- data/app/views/components/_admin_dropdown.html.erb +14 -0
- data/app/views/components/_avatar.html.erb +13 -0
- data/app/views/components/_avatar_cropper.html.erb +135 -0
- data/app/views/components/_badge.html.erb +35 -0
- data/app/views/components/_card.html.erb +4 -0
- data/app/views/components/_copy_button.html.erb +10 -0
- data/app/views/components/_empty_state.html.erb +7 -0
- data/app/views/components/_google_logo.html.erb +1 -0
- data/app/views/components/_input.html.erb +13 -0
- data/app/views/components/_json_debug.html.erb +14 -0
- data/app/views/components/_progress_bar.html.erb +9 -0
- data/app/views/components/_theme_toggle.html.erb +10 -0
- data/app/views/components/_theme_toggle_morph.html.erb +15 -0
- data/app/views/components/_user_nav.html.erb +136 -0
- data/app/views/error_logs/index.html.erb +76 -0
- data/app/views/error_logs/show.html.erb +65 -0
- data/app/views/layouts/_navbar.html.erb +83 -0
- data/app/views/layouts/studio/_flash.html.erb +256 -0
- data/app/views/layouts/studio/_head.html.erb +78 -0
- data/app/views/navbar/show.html.erb +147 -0
- data/app/views/registrations/new.html.erb +80 -0
- data/app/views/schema/index.html.erb +85 -0
- data/app/views/sessions/_sso_continue.html.erb +18 -0
- data/app/views/sessions/new.html.erb +79 -0
- data/app/views/theme_settings/edit.html.erb +376 -0
- data/lib/studio/color_scale.rb +80 -0
- data/lib/studio/engine.rb +12 -0
- data/lib/studio/image_cache.rb +152 -0
- data/lib/studio/s3.rb +72 -0
- data/lib/studio/theme_resolver.rb +99 -0
- data/lib/studio/username_generator.rb +21 -0
- data/lib/studio/version.rb +3 -0
- data/lib/studio-engine.rb +5 -0
- data/lib/studio.rb +134 -0
- data/studio-engine.gemspec +30 -0
- data/tailwind/studio.tailwind.config.js +94 -0
- metadata +189 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class ThemeSetting < ApplicationRecord
|
|
2
|
+
include Sluggable
|
|
3
|
+
|
|
4
|
+
ROLES = %i[primary dark light success warning danger accent].freeze
|
|
5
|
+
|
|
6
|
+
# Map DB columns (accent1/accent2) to role names (success/accent)
|
|
7
|
+
def self.db_column_for(role)
|
|
8
|
+
{ success: :accent1, accent: :accent2 }[role] || role
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
validates :app_name, presence: true, uniqueness: true
|
|
12
|
+
|
|
13
|
+
# Returns the ThemeSetting for the current app, or a new unsaved instance.
|
|
14
|
+
def self.current
|
|
15
|
+
find_by(app_name: Studio.app_name) || new(app_name: Studio.app_name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Merged colors: DB values override Studio.theme_config defaults.
|
|
19
|
+
def resolved_colors
|
|
20
|
+
defaults = Studio.theme_config
|
|
21
|
+
ROLES.each_with_object({}) do |role, hash|
|
|
22
|
+
db_val = read_attribute(self.class.db_column_for(role))
|
|
23
|
+
hash[role] = db_val.presence || defaults[role]
|
|
24
|
+
end.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def name_slug
|
|
28
|
+
"theme-#{app_name.parameterize}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<div x-data="{ open: false }" class="relative leading-none">
|
|
2
|
+
<button @click="open = !open" class="flex text-secondary hover:text-heading transition" title="Admin">
|
|
3
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
|
4
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
|
5
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
6
|
+
</svg>
|
|
7
|
+
</button>
|
|
8
|
+
|
|
9
|
+
<div x-show="open" x-cloak @click.outside="open = false" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" class="absolute right-0 mt-2 w-44 bg-surface border border-subtle rounded-lg shadow-lg z-50 py-1">
|
|
10
|
+
<a href="/admin/theme" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Theme</a>
|
|
11
|
+
<a href="/admin/navbar" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Navbar</a>
|
|
12
|
+
<a href="/error_logs" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Error Logs</a>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# Avatar partial — shows attached image or colored initials circle
|
|
2
|
+
Locals: user (required), size: "sm" | "md" (default) | "lg" %>
|
|
3
|
+
<% size ||= "md" %>
|
|
4
|
+
<% size_classes = { "sm" => "w-6 h-6 text-xs", "nav" => "w-8 h-8 text-xs", "md" => "w-10 h-10 text-sm", "lg" => "w-14 h-14 text-lg" }[size] %>
|
|
5
|
+
|
|
6
|
+
<% if user.avatar.attached? %>
|
|
7
|
+
<%= image_tag user.avatar, class: "#{size_classes.split.first(2).join(' ')} rounded-full object-cover", alt: user.display_name %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<div class="<%= size_classes %> rounded-full flex items-center justify-center font-bold text-white"
|
|
10
|
+
style="background-color: <%= user.avatar_color %>">
|
|
11
|
+
<%= user.avatar_initials %>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<%# locals: (form_field_name: "user[avatar]") %>
|
|
2
|
+
<%# Self-contained avatar cropper: Cropper.js CDN + Alpine.js component %>
|
|
3
|
+
<%# Output: sets a cropped 256x256 PNG File on the hidden file input for standard Rails upload %>
|
|
4
|
+
|
|
5
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
|
6
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
|
7
|
+
|
|
8
|
+
<div x-data="avatarCropper()" class="space-y-3">
|
|
9
|
+
<%# Hidden file input that receives the cropped file %>
|
|
10
|
+
<input type="file" name="<%= form_field_name %>" x-ref="hiddenFileInput" class="hidden">
|
|
11
|
+
|
|
12
|
+
<%# Preview + pick button %>
|
|
13
|
+
<div class="flex items-center gap-4">
|
|
14
|
+
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-surface-alt border-2 border-subtle flex-shrink-0">
|
|
15
|
+
<template x-if="croppedUrl">
|
|
16
|
+
<img :src="croppedUrl" class="w-full h-full object-cover">
|
|
17
|
+
</template>
|
|
18
|
+
<template x-if="!croppedUrl">
|
|
19
|
+
<div class="w-full h-full flex items-center justify-center text-muted">
|
|
20
|
+
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
21
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
22
|
+
</svg>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
</div>
|
|
26
|
+
<button type="button" @click="$refs.filePicker.click()"
|
|
27
|
+
class="btn btn-outline btn-sm">
|
|
28
|
+
<span x-text="croppedUrl ? 'Change Photo' : 'Upload Photo'"></span>
|
|
29
|
+
</button>
|
|
30
|
+
<template x-if="croppedUrl">
|
|
31
|
+
<button type="button" @click="removePhoto()" class="text-muted hover:text-danger text-xs transition">Remove</button>
|
|
32
|
+
</template>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%# Invisible file picker %>
|
|
36
|
+
<input type="file" x-ref="filePicker" @change="onFileSelected($event)" accept="image/*" class="hidden">
|
|
37
|
+
|
|
38
|
+
<%# Crop overlay %>
|
|
39
|
+
<template x-if="showCropModal">
|
|
40
|
+
<div class="fixed inset-0 z-[110] flex items-center justify-center" @keydown.escape.window="cancelCrop()">
|
|
41
|
+
<div class="absolute inset-0 bg-black/70" @click="cancelCrop()"></div>
|
|
42
|
+
<div class="relative bg-surface rounded-2xl border border-subtle shadow-2xl p-6 max-w-md w-full mx-4">
|
|
43
|
+
<h3 class="text-heading font-bold text-lg mb-4">Crop Photo</h3>
|
|
44
|
+
<div class="w-full" style="max-height: 360px;">
|
|
45
|
+
<img x-ref="cropImage" :src="rawImageUrl" class="max-w-full" style="display: block;">
|
|
46
|
+
</div>
|
|
47
|
+
<div class="flex justify-end gap-3 mt-4">
|
|
48
|
+
<button type="button" @click="cancelCrop()" class="btn btn-outline btn-sm">Cancel</button>
|
|
49
|
+
<button type="button" @click="confirmCrop()" class="btn btn-primary btn-sm">Crop & Save</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<script>
|
|
57
|
+
function avatarCropper() {
|
|
58
|
+
return {
|
|
59
|
+
cropper: null,
|
|
60
|
+
showCropModal: false,
|
|
61
|
+
rawImageUrl: null,
|
|
62
|
+
croppedUrl: null,
|
|
63
|
+
|
|
64
|
+
onFileSelected(event) {
|
|
65
|
+
var file = event.target.files[0];
|
|
66
|
+
if (!file) return;
|
|
67
|
+
|
|
68
|
+
var self = this;
|
|
69
|
+
var reader = new FileReader();
|
|
70
|
+
reader.onload = function(e) {
|
|
71
|
+
self.rawImageUrl = e.target.result;
|
|
72
|
+
self.showCropModal = true;
|
|
73
|
+
self.$nextTick(function() {
|
|
74
|
+
self.initCropper();
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
reader.readAsDataURL(file);
|
|
78
|
+
// Reset so same file can be re-selected
|
|
79
|
+
event.target.value = '';
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
initCropper() {
|
|
83
|
+
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
84
|
+
var image = this.$refs.cropImage;
|
|
85
|
+
this.cropper = new Cropper(image, {
|
|
86
|
+
aspectRatio: 1,
|
|
87
|
+
viewMode: 1,
|
|
88
|
+
dragMode: 'move',
|
|
89
|
+
autoCropArea: 0.9,
|
|
90
|
+
cropBoxResizable: true,
|
|
91
|
+
cropBoxMovable: true,
|
|
92
|
+
background: false,
|
|
93
|
+
guides: true
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
confirmCrop() {
|
|
98
|
+
var self = this;
|
|
99
|
+
var canvas = this.cropper.getCroppedCanvas({ width: 256, height: 256 });
|
|
100
|
+
canvas.toBlob(function(blob) {
|
|
101
|
+
// Revoke previous blob URL to prevent memory leak
|
|
102
|
+
if (self.croppedUrl) URL.revokeObjectURL(self.croppedUrl);
|
|
103
|
+
// Set cropped preview
|
|
104
|
+
self.croppedUrl = URL.createObjectURL(blob);
|
|
105
|
+
|
|
106
|
+
// Assign file to hidden input via DataTransfer
|
|
107
|
+
var file = new File([blob], 'avatar.png', { type: 'image/png' });
|
|
108
|
+
var dt = new DataTransfer();
|
|
109
|
+
dt.items.add(file);
|
|
110
|
+
self.$refs.hiddenFileInput.files = dt.files;
|
|
111
|
+
|
|
112
|
+
self.closeCropModal();
|
|
113
|
+
}, 'image/png');
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
cancelCrop() {
|
|
117
|
+
this.closeCropModal();
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
closeCropModal() {
|
|
121
|
+
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
122
|
+
this.showCropModal = false;
|
|
123
|
+
this.rawImageUrl = null;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
removePhoto() {
|
|
127
|
+
if (this.croppedUrl) URL.revokeObjectURL(this.croppedUrl);
|
|
128
|
+
this.croppedUrl = null;
|
|
129
|
+
// Clear the hidden file input
|
|
130
|
+
var dt = new DataTransfer();
|
|
131
|
+
this.$refs.hiddenFileInput.files = dt.files;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%# locals: (text:, scheme: "neutral") %>
|
|
2
|
+
<%#
|
|
3
|
+
Stage roles (stage-fresh ... stage-closed) form a shared pipeline palette
|
|
4
|
+
used by News and Content (and any future pipelines). Mapping:
|
|
5
|
+
stage-fresh → first stage (blue)
|
|
6
|
+
stage-shaping → 2nd stage (yellow)
|
|
7
|
+
stage-structured → 3rd stage (mint)
|
|
8
|
+
stage-refined → 4th stage (emerald)
|
|
9
|
+
stage-cohered → 5th stage (violet)
|
|
10
|
+
stage-shipped → published (emerald)
|
|
11
|
+
stage-closed → archived/done (gray)
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
scheme_classes = case scheme.to_s
|
|
15
|
+
when "success" then "bg-mint/10 text-mint border-mint/30"
|
|
16
|
+
when "danger" then "bg-red-600/10 text-red-400 border-red-600/30"
|
|
17
|
+
when "warning" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
|
|
18
|
+
when "info" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
|
|
19
|
+
when "violet" then "bg-violet/10 text-violet border-violet/30"
|
|
20
|
+
when "primary" then "bg-primary/10 text-primary border-primary/30"
|
|
21
|
+
when "orange" then "bg-orange-500/10 text-orange-500 border-orange-500/30"
|
|
22
|
+
when "emerald" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
23
|
+
when "gray" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
|
|
24
|
+
when "stage-fresh" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
|
|
25
|
+
when "stage-shaping" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
|
|
26
|
+
when "stage-structured" then "bg-mint/10 text-mint border-mint/30"
|
|
27
|
+
when "stage-refined" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
28
|
+
when "stage-cohered" then "bg-violet/10 text-violet border-violet/30"
|
|
29
|
+
when "stage-shipped" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
|
|
30
|
+
when "stage-closed" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
|
|
31
|
+
when "neutral" then "bg-surface-alt text-secondary border-subtle"
|
|
32
|
+
else "bg-surface-alt text-body border-subtle"
|
|
33
|
+
end
|
|
34
|
+
%>
|
|
35
|
+
<span class="badge <%= scheme_classes %>"><%= text %></span>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# locals: (text:, display: nil) %>
|
|
2
|
+
<div class="flex items-center gap-2" x-data="{ copied: false }">
|
|
3
|
+
<code class="bg-inset rounded px-2 py-1 text-primary font-mono text-xs"><%= display || text %></code>
|
|
4
|
+
<button data-copy-text="<%= text %>"
|
|
5
|
+
@click="navigator.clipboard.writeText($el.dataset.copyText); copied = true; setTimeout(() => copied = false, 2000)"
|
|
6
|
+
class="text-muted hover:text-heading transition text-xs" :class="copied && 'text-mint'">
|
|
7
|
+
<span x-show="!copied">Copy</span>
|
|
8
|
+
<span x-show="copied" x-cloak>Copied</span>
|
|
9
|
+
</button>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# locals: (form:, field:, label: nil, type: "text", placeholder: nil, rows: nil) %>
|
|
2
|
+
<div class="mb-5">
|
|
3
|
+
<% if label.present? %>
|
|
4
|
+
<label class="block text-sm text-secondary mb-2 font-medium"><%= label %></label>
|
|
5
|
+
<% end %>
|
|
6
|
+
<% if type.to_s == "textarea" %>
|
|
7
|
+
<%= form.text_area field, rows: rows || 4, class: "input-field", placeholder: placeholder %>
|
|
8
|
+
<% elsif type.to_s == "password" %>
|
|
9
|
+
<%= form.password_field field, class: "input-field", placeholder: placeholder %>
|
|
10
|
+
<% else %>
|
|
11
|
+
<%= form.text_field field, class: "input-field", placeholder: placeholder %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# locals: (record:, label: "JSON", collapsible: false) %>
|
|
2
|
+
<% if collapsible %>
|
|
3
|
+
<details class="mt-8">
|
|
4
|
+
<summary class="label-upper cursor-pointer"><%= label %></summary>
|
|
5
|
+
<div class="json-debug mt-2">
|
|
6
|
+
<pre class="text-mint font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(record.respond_to?(:as_json) ? record.as_json : record) %></pre>
|
|
7
|
+
</div>
|
|
8
|
+
</details>
|
|
9
|
+
<% else %>
|
|
10
|
+
<div class="json-debug">
|
|
11
|
+
<p class="label-upper mb-2"><%= label %></p>
|
|
12
|
+
<pre class="text-mint font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(record.respond_to?(:as_json) ? record.as_json : record) %></pre>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# locals: (percent:, height: "h-3", color: "var(--color-cta)", label: nil, animated: false) %>
|
|
2
|
+
<div class="w-full <%= height %> rounded-full bg-surface-alt overflow-hidden">
|
|
3
|
+
<div class="h-full rounded-full<%= ' transition-all duration-500 ease-out' if animated %>"
|
|
4
|
+
style="width: <%= [percent.to_f, 100].min %>%; background-color: <%= color %>">
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
7
|
+
<% if label %>
|
|
8
|
+
<p class="text-xs text-muted mt-1"><%= label %></p>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<button @click="$store.theme.toggle()" class="flex text-secondary hover:text-heading transition" title="Toggle theme">
|
|
2
|
+
<!-- Moon icon (shown in dark mode) — Heroicons v2 outline -->
|
|
3
|
+
<svg x-show="$store.theme.isDark" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
4
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
|
5
|
+
</svg>
|
|
6
|
+
<!-- Sun icon (shown in light mode) — Heroicons v2 outline -->
|
|
7
|
+
<svg x-show="!$store.theme.isDark" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
8
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
|
9
|
+
</svg>
|
|
10
|
+
</button>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# Theme toggle with scale-morph spinner swap.
|
|
2
|
+
showNavSpinner() / hideNavSpinner() (defined in _head.html.erb) cross-fade between
|
|
3
|
+
the theme toggle and a loading spinner with a scale+rotate animation.
|
|
4
|
+
%>
|
|
5
|
+
<span class="relative flex" style="width: 16px; height: 16px;">
|
|
6
|
+
<span class="nav-toggle-icon absolute inset-0 transition-all duration-300" style="transform: scale(1) rotate(0deg); opacity: 1;">
|
|
7
|
+
<%= render "components/theme_toggle" %>
|
|
8
|
+
</span>
|
|
9
|
+
<span class="nav-spinner-icon absolute inset-0 transition-all duration-300 pointer-events-none" style="transform: scale(0) rotate(-90deg); opacity: 0;">
|
|
10
|
+
<svg class="w-4 h-4 animate-spin text-secondary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
11
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
12
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
13
|
+
</svg>
|
|
14
|
+
</span>
|
|
15
|
+
</span>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<%# Shared right-side navbar user section.
|
|
2
|
+
Locals (all optional):
|
|
3
|
+
balance_html — raw HTML for balance display (e.g. Turf Monster wallet balance)
|
|
4
|
+
extra_icons_html — raw HTML for app-specific icon buttons (e.g. refresh button)
|
|
5
|
+
show_logout_link — boolean, show explicit "Log out" link (Studio uses this)
|
|
6
|
+
div2_html — raw HTML to replace default Div 2 (e.g. Turf Monster seeds bar)
|
|
7
|
+
%>
|
|
8
|
+
<% balance_html ||= nil %>
|
|
9
|
+
<% extra_icons_html ||= nil %>
|
|
10
|
+
<% show_logout_link ||= false %>
|
|
11
|
+
<% div2_html ||= nil %>
|
|
12
|
+
|
|
13
|
+
<div class="flex gap-2">
|
|
14
|
+
<% if logged_in? %>
|
|
15
|
+
<%# Left column: Div 1 + Div 2 stacked %>
|
|
16
|
+
<div class="flex-1">
|
|
17
|
+
<%# Div 1 (Bigger): balance + icons + username %>
|
|
18
|
+
<div class="flex items-center gap-2 leading-none" :style="'padding: 0 6px;' + ($store.devMode ? 'background: cornflowerblue;' : '')">
|
|
19
|
+
<% if balance_html.present? %>
|
|
20
|
+
<%= raw balance_html %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<div class="flex items-center gap-2 ml-auto self-end">
|
|
23
|
+
<%= raw extra_icons_html %>
|
|
24
|
+
<%= render "components/admin_dropdown" %>
|
|
25
|
+
<%= render "components/theme_toggle_morph" %>
|
|
26
|
+
</div>
|
|
27
|
+
<% account_link = defined?(account_path) ? account_path : "#" %>
|
|
28
|
+
<%= link_to account_link, class: "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" do %>
|
|
29
|
+
<%= current_user.display_name %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
<%# Div 2 (Smaller): wallet address (left) + level (right) with progress bar %>
|
|
33
|
+
<% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
|
|
34
|
+
<div x-data="{
|
|
35
|
+
barWidth: 0,
|
|
36
|
+
displayLevel: <%= server_level || 1 %>,
|
|
37
|
+
leveledUp: false,
|
|
38
|
+
init() {
|
|
39
|
+
var self = this;
|
|
40
|
+
var data = this.getSeedsData();
|
|
41
|
+
if (data) {
|
|
42
|
+
self.displayLevel = data.level;
|
|
43
|
+
setTimeout(function() { self.barWidth = data.progress; }, 150);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
getSeedsData() {
|
|
47
|
+
try {
|
|
48
|
+
var raw = localStorage.getItem('seedsNavbar');
|
|
49
|
+
if (!raw) return null;
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
} catch(e) { return null; }
|
|
52
|
+
},
|
|
53
|
+
replayLevel() {
|
|
54
|
+
var self = this;
|
|
55
|
+
var data = this.getSeedsData();
|
|
56
|
+
var finalPct = data ? data.progress : self.barWidth;
|
|
57
|
+
self.displayLevel = Math.max(1, self.displayLevel - 1);
|
|
58
|
+
self.barWidth = 60;
|
|
59
|
+
setTimeout(function() { self.barWidth = 100; }, 300);
|
|
60
|
+
setTimeout(function() { self.displayLevel += 1; self.leveledUp = true; }, 1500);
|
|
61
|
+
setTimeout(function() { self.leveledUp = false; self.barWidth = 0; }, 2800);
|
|
62
|
+
setTimeout(function() { self.barWidth = finalPct; }, 3000);
|
|
63
|
+
},
|
|
64
|
+
handleSeedsUpdate(d) {
|
|
65
|
+
var self = this;
|
|
66
|
+
if (d.levelUp) {
|
|
67
|
+
self.barWidth = d.oldPct;
|
|
68
|
+
setTimeout(function() { self.barWidth = 100; }, 300);
|
|
69
|
+
setTimeout(function() { self.displayLevel = d.newLevel; self.leveledUp = true; }, 1500);
|
|
70
|
+
setTimeout(function() { self.leveledUp = false; self.barWidth = 0; }, 2800);
|
|
71
|
+
setTimeout(function() { self.barWidth = d.progress; }, 3000);
|
|
72
|
+
} else {
|
|
73
|
+
self.displayLevel = d.level;
|
|
74
|
+
self.barWidth = d.progress;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}"
|
|
78
|
+
@navbar-replay-level.window="replayLevel()"
|
|
79
|
+
@navbar-seeds-update.window="handleSeedsUpdate($event.detail)"
|
|
80
|
+
class="relative mt-0.5" style="height: 14px;">
|
|
81
|
+
<%# Fill bar %>
|
|
82
|
+
<div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
|
|
83
|
+
<%# Text layer 1: muted (visible outside bar) %>
|
|
84
|
+
<div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
|
|
85
|
+
<span>
|
|
86
|
+
<% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
|
|
87
|
+
<%= current_user.truncated_solana %>
|
|
88
|
+
<% end %>
|
|
89
|
+
</span>
|
|
90
|
+
<% if server_level %>
|
|
91
|
+
<span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
|
|
92
|
+
<% elsif show_logout_link %>
|
|
93
|
+
<%= link_to "Log out", logout_path, class: "font-mono text-muted no-underline nav-bar-text" %>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
96
|
+
<%# Text layer 2: white (clip-path reveals where bar covers) %>
|
|
97
|
+
<div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-white z-[2] nav-bar-text" :style="{ 'clip-path': 'inset(0 ' + (100 - barWidth) + '% 0 0)' }">
|
|
98
|
+
<span>
|
|
99
|
+
<% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
|
|
100
|
+
<%= current_user.truncated_solana %>
|
|
101
|
+
<% end %>
|
|
102
|
+
</span>
|
|
103
|
+
<% if server_level %>
|
|
104
|
+
<span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
|
|
105
|
+
<% elsif show_logout_link %>
|
|
106
|
+
<%= link_to "Log out", logout_path, class: "font-mono text-white no-underline nav-bar-text" %>
|
|
107
|
+
<% end %>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<style>
|
|
111
|
+
@keyframes navLevelPop {
|
|
112
|
+
0% { transform: scale(1); }
|
|
113
|
+
15% { transform: scale(1.5); }
|
|
114
|
+
35% { transform: scale(1.2); }
|
|
115
|
+
50% { transform: scale(1.4); }
|
|
116
|
+
70% { transform: scale(1.1); }
|
|
117
|
+
100% { transform: scale(1); }
|
|
118
|
+
}
|
|
119
|
+
.nav-level-pop { animation: navLevelPop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
120
|
+
.nav-bar-text { font-size: 10px; }
|
|
121
|
+
</style>
|
|
122
|
+
</div>
|
|
123
|
+
<%# Div 3 (Avatar): spans both rows %>
|
|
124
|
+
<% account_link = defined?(account_path) ? account_path : "#" %>
|
|
125
|
+
<%= link_to account_link, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
|
|
126
|
+
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
127
|
+
<% end %>
|
|
128
|
+
<% else %>
|
|
129
|
+
<%= render "components/admin_dropdown" %>
|
|
130
|
+
<%= render "components/theme_toggle_morph" %>
|
|
131
|
+
<div class="flex items-center gap-3 ml-auto">
|
|
132
|
+
<%= link_to "Log in", login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
|
|
133
|
+
<%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>
|
|
134
|
+
</div>
|
|
135
|
+
<% end %>
|
|
136
|
+
</div>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<section class="mb-6" x-data="{
|
|
2
|
+
loading: false,
|
|
3
|
+
search(url) {
|
|
4
|
+
this.loading = true;
|
|
5
|
+
setTimeout(() => { window.location.href = url }, 500);
|
|
6
|
+
}
|
|
7
|
+
}">
|
|
8
|
+
<div class="flex items-center justify-between mb-4">
|
|
9
|
+
<h2 class="text-2xl font-bold text-heading">Error Logs</h2>
|
|
10
|
+
<span class="text-sm text-muted"><%= @error_logs.size %> <%= params[:q].present? ? "results" : "recent errors" %></span>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<form method="get" action="<%= error_logs_path %>" class="mb-6 relative"
|
|
14
|
+
@submit.prevent="
|
|
15
|
+
const q = $refs.input.value.trim();
|
|
16
|
+
const url = q ? '<%= error_logs_path %>?q=' + encodeURIComponent(q) : '<%= error_logs_path %>';
|
|
17
|
+
search(url);
|
|
18
|
+
"
|
|
19
|
+
@keydown.escape.prevent="
|
|
20
|
+
if ($refs.input.value) {
|
|
21
|
+
$refs.input.value = '';
|
|
22
|
+
search('<%= error_logs_path %>');
|
|
23
|
+
}
|
|
24
|
+
">
|
|
25
|
+
<input type="text" name="q" value="<%= params[:q] %>" placeholder="Search errors... (Esc to clear)" autofocus x-ref="input"
|
|
26
|
+
class="input-field pr-10 py-2.5 text-sm placeholder-muted"
|
|
27
|
+
:readonly="loading" />
|
|
28
|
+
<div x-show="loading" x-cloak class="absolute right-3 top-1/2 -translate-y-1/2">
|
|
29
|
+
<svg class="animate-spin h-4 w-4 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
30
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
31
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
32
|
+
</svg>
|
|
33
|
+
</div>
|
|
34
|
+
</form>
|
|
35
|
+
|
|
36
|
+
<!-- Loading state -->
|
|
37
|
+
<div x-show="loading" x-cloak class="py-16 text-center">
|
|
38
|
+
<svg class="animate-spin h-8 w-8 text-primary mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
39
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
40
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
41
|
+
</svg>
|
|
42
|
+
<p class="text-muted text-sm">Searching...</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- Results -->
|
|
46
|
+
<div x-show="!loading">
|
|
47
|
+
<% if @error_logs.empty? %>
|
|
48
|
+
<% if params[:q].present? %>
|
|
49
|
+
<%= render "components/empty_state", message: "No errors matching \"#{params[:q]}\"" %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<%= render "components/empty_state", message: "No errors logged yet.", detail: "Errors will appear here when captured via ErrorLog.capture!" %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% else %>
|
|
54
|
+
<div class="space-y-2">
|
|
55
|
+
<% @error_logs.each do |log| %>
|
|
56
|
+
<%= link_to error_log_path(log, q: params[:q].presence), class: "block card p-4 hover:border-red-700/50 hover:shadow-lg hover:shadow-red-900/10 transition" do %>
|
|
57
|
+
<div class="flex items-start justify-between gap-4">
|
|
58
|
+
<div class="flex-1 min-w-0">
|
|
59
|
+
<p class="text-red-400 font-semibold text-sm truncate"><%= log.message %></p>
|
|
60
|
+
<% if log.backtrace.present? %>
|
|
61
|
+
<p class="text-muted font-mono text-xs mt-1 truncate"><%= JSON.parse(log.backtrace).first %></p>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="flex items-center gap-3 shrink-0">
|
|
65
|
+
<% if log.target_name.present? %>
|
|
66
|
+
<%= render "components/badge", text: log.target_name, scheme: "violet" %>
|
|
67
|
+
<% end %>
|
|
68
|
+
<span class="text-xs text-muted whitespace-nowrap"><%= time_ago_in_words(log.created_at) %> ago</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<% end %>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<div class="mb-6">
|
|
3
|
+
<% back_path = params[:q].present? ? error_logs_path(q: params[:q]) : error_logs_path %>
|
|
4
|
+
<% back_text = params[:q].present? ? "Back to \"#{params[:q]}\"" : "All Errors" %>
|
|
5
|
+
<%= link_to back_path, class: "text-secondary hover:text-heading text-sm transition" do %>
|
|
6
|
+
<span>←</span> <%= back_text %>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="card p-6 mb-6">
|
|
11
|
+
<div class="flex items-start justify-between mb-4">
|
|
12
|
+
<h2 class="text-xl font-bold text-red-400"><%= @error_log.message %></h2>
|
|
13
|
+
<span class="text-xs text-muted whitespace-nowrap ml-4"><%= @error_log.created_at.strftime("%b %d, %Y %I:%M:%S %p") %></span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<!-- Metadata -->
|
|
17
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
18
|
+
<div class="bg-surface-alt rounded-lg p-3">
|
|
19
|
+
<p class="label-upper mb-1">Target</p>
|
|
20
|
+
<% if @error_log.target_type.present? %>
|
|
21
|
+
<p class="text-heading font-semibold text-sm"><%= @error_log.target_name %></p>
|
|
22
|
+
<div class="mt-1.5">
|
|
23
|
+
<%= render "components/copy_button", text: "#{@error_log.target_type}.find_by(id: #{@error_log.target_id})" %>
|
|
24
|
+
</div>
|
|
25
|
+
<% else %>
|
|
26
|
+
<p class="text-muted text-sm">None</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="bg-surface-alt rounded-lg p-3">
|
|
30
|
+
<p class="label-upper mb-1">Parent</p>
|
|
31
|
+
<% if @error_log.parent_type.present? %>
|
|
32
|
+
<p class="text-heading font-semibold text-sm"><%= @error_log.parent_name %></p>
|
|
33
|
+
<div class="mt-1.5">
|
|
34
|
+
<%= render "components/copy_button", text: "#{@error_log.parent_type}.find_by(id: #{@error_log.parent_id})" %>
|
|
35
|
+
</div>
|
|
36
|
+
<% else %>
|
|
37
|
+
<p class="text-muted text-sm">None</p>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Inspect -->
|
|
43
|
+
<div class="mb-6">
|
|
44
|
+
<p class="label-upper mb-2">Exception</p>
|
|
45
|
+
<pre class="bg-inset rounded-lg p-4 text-red-300 font-mono text-xs overflow-x-auto"><%= @error_log.inspect_field %></pre>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Backtrace -->
|
|
49
|
+
<% if @error_log.backtrace.present? %>
|
|
50
|
+
<div>
|
|
51
|
+
<p class="label-upper mb-2">Backtrace</p>
|
|
52
|
+
<div class="bg-inset rounded-lg p-4 overflow-x-auto">
|
|
53
|
+
<% JSON.parse(@error_log.backtrace).each_with_index do |frame, i| %>
|
|
54
|
+
<p class="font-mono text-xs leading-relaxed <%= i == 0 ? 'text-red-300' : 'text-muted' %>"><%= frame %></p>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Slug -->
|
|
62
|
+
<p class="text-xs text-muted text-center mb-6"><%= @error_log.slug %></p>
|
|
63
|
+
|
|
64
|
+
<%= render "components/json_debug", record: @error_log %>
|
|
65
|
+
</section>
|