protege 0.1.0.alpha.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/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# The +<details>+-based dropdown menu: +dropdown_menu+ renders the trigger + panel (with a backdrop
|
|
6
|
+
# that closes it), and +dropdown_item+ renders each entry as a link or form button, with an +active+
|
|
7
|
+
# variant for filter-style menus.
|
|
8
|
+
module MenusHelper
|
|
9
|
+
# Render a +<details>+-based dropdown menu with a backdrop that closes it.
|
|
10
|
+
#
|
|
11
|
+
# @param label [String] the trigger text (defaults to a kebab +···+)
|
|
12
|
+
# @yield the menu items, rendered inside the dropdown panel
|
|
13
|
+
# @return [ActiveSupport::SafeBuffer] the dropdown markup
|
|
14
|
+
def dropdown_menu(label: '···', &block)
|
|
15
|
+
tag.details(class: 'relative') do
|
|
16
|
+
tag.summary(label, class: 'cursor-pointer list-none text-base font-bold select-none',
|
|
17
|
+
style: 'color: var(--text-faint)') +
|
|
18
|
+
tag.div(class: 'fixed inset-0 z-10', onclick: "this.closest('details').removeAttribute('open')") +
|
|
19
|
+
tag.div(class: 'absolute right-0 mt-1 w-36 rounded-lg py-1 shadow-lg z-20',
|
|
20
|
+
style: 'background: var(--surface); border: 1px solid var(--border)') { capture(&block) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Render a single dropdown entry, either a link or a form button. Pass +active: true+ to mark the
|
|
25
|
+
# current selection — the entry is emphasized and gets a trailing check (for filter-style menus).
|
|
26
|
+
#
|
|
27
|
+
# @param text [String] the entry label
|
|
28
|
+
# @param href [String] the entry target
|
|
29
|
+
# @param method [Symbol, nil] when set, render a form button using this verb
|
|
30
|
+
# @param active [Boolean] whether this entry is the current selection
|
|
31
|
+
# @param destructive [Boolean] whether to apply destructive (red) styling
|
|
32
|
+
# @return [ActiveSupport::SafeBuffer] the entry markup
|
|
33
|
+
def dropdown_item(text, href, method: nil, active: false, destructive: false)
|
|
34
|
+
text_class = destructive ? 'text-red-500 hover:text-red-700' : ''
|
|
35
|
+
weight = active ? 'font-semibold' : ''
|
|
36
|
+
base_class = 'flex w-full items-center justify-between gap-2 text-left text-sm px-3 py-1.5 ' \
|
|
37
|
+
"cursor-pointer #{text_class} #{weight}".squish
|
|
38
|
+
content = active ? safe_join([text, tag.span('✓', style: 'color: var(--color-amber-500, #f59e0b)')]) : text
|
|
39
|
+
|
|
40
|
+
if method
|
|
41
|
+
button_to content, href,
|
|
42
|
+
method:,
|
|
43
|
+
class: "#{base_class} bg-transparent border-0",
|
|
44
|
+
form: { class: 'w-full' }
|
|
45
|
+
else
|
|
46
|
+
link_to content, href, class: base_class
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Render a navigation link, highlighted when the current path is under it.
|
|
51
|
+
#
|
|
52
|
+
# @param text [String] the link text
|
|
53
|
+
# @param href [String] the link target
|
|
54
|
+
# @param current_path [String] the request path used to compute the active state
|
|
55
|
+
# @return [ActiveSupport::SafeBuffer] the nav-item markup
|
|
56
|
+
def nav_item(text, href, current_path)
|
|
57
|
+
active = current_path.start_with?(href)
|
|
58
|
+
classes = 'rounded-sm px-2.5 py-1 text-xs font-medium transition-colors'
|
|
59
|
+
style = if active
|
|
60
|
+
'background: var(--surface-subtle); color: var(--text)'
|
|
61
|
+
else
|
|
62
|
+
'color: var(--text-muted)'
|
|
63
|
+
end
|
|
64
|
+
link_to(text, href, class: classes, style:)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Pagination for sidebar lists: +paginate+ slices a relation into a page of records plus the page
|
|
6
|
+
# metadata, and +sidebar_pagination+ renders that metadata as the sidebar's bottom bar — a prev
|
|
7
|
+
# arrow, the first/current/last page numbers, and a next arrow.
|
|
8
|
+
#
|
|
9
|
+
# Pagy supplies only the page math (count + page → offset/limit and the prev/next/last numbers); the
|
|
10
|
+
# markup is ours so it matches the sidebar styling. Colors use the same CSS custom properties as the
|
|
11
|
+
# rest of the layout (--text, --text-muted, --text-faint, --border) so dark mode follows along.
|
|
12
|
+
module PaginationHelper
|
|
13
|
+
# Paginate a relation for a sidebar list, returning the page metadata and the page of records.
|
|
14
|
+
#
|
|
15
|
+
# Wraps Pagy's standalone object (no +Pagy::Backend+ controller mixin needed): it counts the scope,
|
|
16
|
+
# resolves the requested page, and slices the relation with +offset+/+limit+. The page defaults to
|
|
17
|
+
# the +?page=+ query param; a +nil+ or non-numeric value falls back to page 1, and an overflowing
|
|
18
|
+
# one is clamped to the last page (the +:last_page+ overflow default set at boot).
|
|
19
|
+
#
|
|
20
|
+
# @param scope [ActiveRecord::Relation] the relation to page through
|
|
21
|
+
# @param page [String, Integer, nil] the requested page (defaults to +params[:page]+)
|
|
22
|
+
# @param limit [Integer] the page size
|
|
23
|
+
# @return [Array(Pagy, ActiveRecord::Relation)] the pagy metadata and the page of records
|
|
24
|
+
def paginate(scope, page: params[:page], limit: 15)
|
|
25
|
+
pagy = ::Pagy.new(count: scope.count, page: [page.to_i, 1].max, limit:)
|
|
26
|
+
[pagy, scope.offset(pagy.offset).limit(pagy.limit)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Render the sidebar's bottom pagination bar from a Pagy object — a prev arrow, the
|
|
30
|
+
# first/current/last page numbers, and a next arrow. Mirrors +sidebar_header+ (same bar shape, a
|
|
31
|
+
# top border) but pinned to the bottom of the sidebar. Renders nothing for a single page, so a
|
|
32
|
+
# sidebar can render it unconditionally and it only appears once the list outgrows one page.
|
|
33
|
+
#
|
|
34
|
+
# Links target the current path with a merged +:page+, so paging the sidebar keeps the main view
|
|
35
|
+
# (e.g. an open thread) in place and re-pages only the list.
|
|
36
|
+
#
|
|
37
|
+
# @param pagy [Pagy] the page metadata for the sidebar's collection
|
|
38
|
+
# @return [ActiveSupport::SafeBuffer] the footer markup, or an empty buffer for a single page
|
|
39
|
+
def sidebar_pagination(pagy)
|
|
40
|
+
return ''.html_safe if pagy.pages <= 1
|
|
41
|
+
|
|
42
|
+
tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4 text-xs',
|
|
43
|
+
style: 'border-top: 1px solid var(--border)') do
|
|
44
|
+
[
|
|
45
|
+
sidebar_page_arrow('◀︎', pagy.prev),
|
|
46
|
+
sidebar_page_numbers(pagy),
|
|
47
|
+
sidebar_page_arrow('▶︎', pagy.next)
|
|
48
|
+
].join.html_safe
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Render a pagination arrow — a link to +page+, or a faint inert span at the first/last boundary
|
|
55
|
+
# (when +page+ is +nil+).
|
|
56
|
+
#
|
|
57
|
+
# @param glyph [String] the arrow character
|
|
58
|
+
# @param page [Integer, nil] the target page, or nil to disable the arrow
|
|
59
|
+
# @return [ActiveSupport::SafeBuffer] the arrow markup
|
|
60
|
+
def sidebar_page_arrow(glyph, page)
|
|
61
|
+
classes = 'px-1'
|
|
62
|
+
return tag.span(glyph, class: classes, style: 'color: var(--text-faint)') unless page
|
|
63
|
+
|
|
64
|
+
link_to(glyph, sidebar_page_url(page), class: "#{classes} text-amber-600 hover:text-amber-700")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Render the middle page numbers — the distinct first/current/last pages in order, dot-separated,
|
|
68
|
+
# with the current page emphasized and the others linked.
|
|
69
|
+
#
|
|
70
|
+
# @param pagy [Pagy] the page metadata
|
|
71
|
+
# @return [ActiveSupport::SafeBuffer] the page-number markup
|
|
72
|
+
def sidebar_page_numbers(pagy)
|
|
73
|
+
pages = [1, pagy.page, pagy.last].uniq.sort
|
|
74
|
+
separator = tag.span('·', class: 'px-1', style: 'color: var(--text-faint)')
|
|
75
|
+
|
|
76
|
+
safe_join(pages.map { |n| sidebar_page_number(n, current: n == pagy.page) }, separator)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Render a single middle page number — an emphasized span for the current page, a link otherwise.
|
|
80
|
+
#
|
|
81
|
+
# @param page [Integer] the page number
|
|
82
|
+
# @param current [Boolean] whether this is the current page
|
|
83
|
+
# @return [ActiveSupport::SafeBuffer] the page-number markup
|
|
84
|
+
def sidebar_page_number(page, current:)
|
|
85
|
+
return tag.span(page, class: 'font-semibold', style: 'color: var(--text)') if current
|
|
86
|
+
|
|
87
|
+
ui_link(page.to_s, sidebar_page_url(page))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build the URL for a sidebar page — the current path with +:page+ merged into its query, so the
|
|
91
|
+
# main view stays put while the sidebar re-pages.
|
|
92
|
+
#
|
|
93
|
+
# @param page [Integer] the target page
|
|
94
|
+
# @return [String] the path with the merged page param
|
|
95
|
+
def sidebar_page_url(page)
|
|
96
|
+
"#{request.path}?#{request.query_parameters.merge('page' => page).to_query}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# The console sidebar surface — the docked +<aside>+ shell and the header/list/footer elements that
|
|
6
|
+
# fill it. Split out of +LayoutHelper+ so the sidebar is a single focused concern: +sidebar_container+
|
|
7
|
+
# is the resizable outer shell, +sidebar_content+ the flex column inside it, and +sidebar_header+ /
|
|
8
|
+
# +sidebar_search_link+ / +sidebar_body+ / +sidebar_item+ / +sidebar_stream+ compose the list.
|
|
9
|
+
#
|
|
10
|
+
# Colors use the CSS custom properties (--surface, --border, --text, --text-muted, --text-faint)
|
|
11
|
+
# defined in tailwind/application.css so dark mode works via a single .dark class toggle.
|
|
12
|
+
module SidebarHelper
|
|
13
|
+
# Render the sidebar shell — the resizable, full-height +<aside>+ that docks the navigation sidebar
|
|
14
|
+
# on the left of the console. The user can drag its right edge to resize it (the general
|
|
15
|
+
# +resize_handle+ paired with +resize.js+), bounded by min/max and remembered across page loads. The
|
|
16
|
+
# yielded block is the sidebar's content (typically a +sidebar_content+ wrapper).
|
|
17
|
+
#
|
|
18
|
+
# @yield the sidebar contents
|
|
19
|
+
# @return [ActiveSupport::SafeBuffer] the aside markup
|
|
20
|
+
def sidebar_container(&block)
|
|
21
|
+
tag.aside(class: 'relative flex w-72 flex-shrink-0 flex-col overflow-hidden',
|
|
22
|
+
data: { resize: '', resize_edge: 'right', resize_min: 200,
|
|
23
|
+
resize_max: 480, resize_key: 'protege:sidebar-width' }) do
|
|
24
|
+
resize_handle(edge: :right) + capture(&block)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Wrap a sidebar's contents (header, scrollable list, pagination footer) in the flex column that
|
|
29
|
+
# fills the sidebar shell — header/footer stay fixed while the list scrolls between them.
|
|
30
|
+
#
|
|
31
|
+
# Pass +frame:+ to render the column as a Turbo Frame (an update target a form/stream can replace)
|
|
32
|
+
# instead of a plain div; the same layout classes apply either way. A bare +<turbo-frame>+ is
|
|
33
|
+
# +display: inline+, so the classes (which set it +flex+) are what make the frame variant lay out at all.
|
|
34
|
+
#
|
|
35
|
+
# @param frame [String, nil] a Turbo Frame id to render as, or nil for a plain div
|
|
36
|
+
# @yield the sidebar contents
|
|
37
|
+
# @return [ActiveSupport::SafeBuffer] the column markup
|
|
38
|
+
def sidebar_content(frame: nil, &)
|
|
39
|
+
classes = 'flex min-h-0 flex-1 flex-col'
|
|
40
|
+
frame ? turbo_frame_tag(frame, class: classes, &) : tag.div(class: classes, &)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Render the scrollable list area of a sidebar — the flexible middle that grows between the fixed
|
|
44
|
+
# header and pagination footer.
|
|
45
|
+
#
|
|
46
|
+
# Two forms:
|
|
47
|
+
# * +sidebar_body(collection, empty: '…') { |item| … }+ renders each item via the block, or a muted
|
|
48
|
+
# empty-state hint when the collection is empty — the common list sidebar.
|
|
49
|
+
# * +sidebar_body { … }+ (no collection) just wraps arbitrary content, for sidebars that own their
|
|
50
|
+
# own iteration/empty handling (e.g. the Inbox's Turbo Frame, the search results' two empty states).
|
|
51
|
+
#
|
|
52
|
+
# @param collection [Enumerable, nil] the items to render, or nil for the block-only form
|
|
53
|
+
# @param empty [String, nil] the empty-state message for the collection form
|
|
54
|
+
# @param id [String, nil] DOM id for the scroll container — the live-append broadcast target
|
|
55
|
+
# @yield [item] each item (collection form), or the raw body (block-only form)
|
|
56
|
+
# @return [ActiveSupport::SafeBuffer] the scrollable body markup
|
|
57
|
+
def sidebar_body(collection = nil, empty: nil, id: nil, &block)
|
|
58
|
+
tag.div(class: 'flex-1 overflow-y-auto', id:) do
|
|
59
|
+
if collection.nil?
|
|
60
|
+
capture(&block)
|
|
61
|
+
elsif collection.any?
|
|
62
|
+
safe_join(collection.map { |item| capture(item, &block) })
|
|
63
|
+
else
|
|
64
|
+
hint(empty, classes: 'py-8 text-center')
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Subscribe the sidebar to a Turbo Stream for live-appended rows — but only on the first page.
|
|
70
|
+
#
|
|
71
|
+
# New rows are broadcast (prepend/append) into +sidebar_body+'s +id+ target; gating the
|
|
72
|
+
# *subscription* to page 1 is how we keep live rows from landing on the wrong page — later pages
|
|
73
|
+
# simply aren't subscribed and reconcile on their next load. Row-replace updates are id-targeted,
|
|
74
|
+
# so they're unaffected by whether this subscription is present.
|
|
75
|
+
#
|
|
76
|
+
# @param streamables [Array] the Turbo stream identifier(s) (e.g. a string, or +[record, :scope]+)
|
|
77
|
+
# @param pagy [Pagy] the list's pagination — the subscription renders only when +pagy.page == 1+
|
|
78
|
+
# @return [ActiveSupport::SafeBuffer] the stream-source tag on page 1, else an empty buffer
|
|
79
|
+
def sidebar_stream(*streamables, pagy:)
|
|
80
|
+
return ''.html_safe unless pagy.page == 1
|
|
81
|
+
|
|
82
|
+
turbo_stream_from(*streamables)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Render a fixed-height sidebar bar — the standard horizontal strip (bordered, faint, padded) used
|
|
86
|
+
# for a labeled control row such as the trace filter. Its contents are yielded and laid out with
|
|
87
|
+
# space-between, matching the +sidebar_header+/+sidebar_search_link+ bar shape.
|
|
88
|
+
#
|
|
89
|
+
# @yield the bar contents (typically a label span and a control)
|
|
90
|
+
# @return [ActiveSupport::SafeBuffer] the bar markup
|
|
91
|
+
def sidebar_bar(&)
|
|
92
|
+
tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4 text-xs',
|
|
93
|
+
style: 'border-bottom: 1px solid var(--border); color: var(--text-faint)', &)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Render a sidebar section header with an optional "new record" link.
|
|
97
|
+
#
|
|
98
|
+
# @param title [String] the section title
|
|
99
|
+
# @param new_path [String, nil] optional target for the new-record link
|
|
100
|
+
# @param new_label [String] the new-record link label
|
|
101
|
+
# @return [ActiveSupport::SafeBuffer] the sidebar header markup
|
|
102
|
+
def sidebar_header(title, new_path: nil, new_label: 'New')
|
|
103
|
+
tag.div(class: 'flex h-10 flex-shrink-0 items-center justify-between px-4',
|
|
104
|
+
style: 'border-bottom: 1px solid var(--border)') do
|
|
105
|
+
tag.span(title, class: 'text-xs font-semibold uppercase tracking-wider', style: 'color: var(--text-faint)') +
|
|
106
|
+
(new_path ? ui_link(new_label, new_path) : ''.html_safe)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Render a sidebar search affordance — a faux search field that links to a search page.
|
|
111
|
+
#
|
|
112
|
+
# Styled like an input (magnifier + muted placeholder) but it is a plain link: clicking it
|
|
113
|
+
# navigates to +href+. Sits between the sidebar header and its list.
|
|
114
|
+
#
|
|
115
|
+
# @param href [String] the search page to open
|
|
116
|
+
# @param placeholder [String] the muted prompt text
|
|
117
|
+
# @return [ActiveSupport::SafeBuffer] the search-bar markup
|
|
118
|
+
def sidebar_search_link(href, placeholder: 'Search…')
|
|
119
|
+
classes = 'flex h-10 flex-shrink-0 items-center gap-2 px-4 text-xs transition-colors hover:opacity-80'
|
|
120
|
+
link_to(href, class: classes, style: 'border-bottom: 1px solid var(--border); color: var(--text-faint)') do
|
|
121
|
+
search_icon + tag.span(placeholder)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Render a sidebar list entry — title, optional detail/subtitle, and an
|
|
126
|
+
# optional extra block — highlighted when +active+.
|
|
127
|
+
#
|
|
128
|
+
# @param title [String] the entry title
|
|
129
|
+
# @param href [String] the entry target
|
|
130
|
+
# @param detail [String, ActiveSupport::SafeBuffer, nil] right-aligned detail text or markup
|
|
131
|
+
# @param subtitle [String, nil] optional subtitle line
|
|
132
|
+
# @param active [Boolean] whether to highlight the entry as current
|
|
133
|
+
# @param attrs [Hash] extra HTML attributes forwarded to the link
|
|
134
|
+
# @yield optional extra content rendered beneath the subtitle
|
|
135
|
+
# @return [ActiveSupport::SafeBuffer] the sidebar entry markup
|
|
136
|
+
def sidebar_item(title, href, detail: nil, subtitle: nil, active: false, **attrs, &block)
|
|
137
|
+
link_to(href, class: 'block px-4 py-3 transition-colors', style: sidebar_item_style(active), **attrs) do
|
|
138
|
+
sidebar_item_header(title, detail) +
|
|
139
|
+
sidebar_item_subtitle(subtitle) +
|
|
140
|
+
(block ? tag.div(class: 'mt-1', &block) : ''.html_safe)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# The magnifier glyph for +sidebar_search_link+ — a small inline SVG (not an emoji) that inherits
|
|
147
|
+
# the link's color via +currentColor+, so it tracks the theme.
|
|
148
|
+
#
|
|
149
|
+
# @return [ActiveSupport::SafeBuffer] the inline SVG markup
|
|
150
|
+
def search_icon
|
|
151
|
+
raw <<~SVG
|
|
152
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0">
|
|
153
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
154
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
155
|
+
</svg>
|
|
156
|
+
SVG
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── Sidebar item helpers ─────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# Build the inline style for a sidebar entry, highlighting it when active.
|
|
162
|
+
#
|
|
163
|
+
# @param active [Boolean] whether the entry is the current one
|
|
164
|
+
# @return [String] the entry's CSS style string
|
|
165
|
+
def sidebar_item_style(active)
|
|
166
|
+
if active
|
|
167
|
+
'background: var(--surface-subtle); border-left: 2px solid var(--color-amber-500, #f59e0b)'
|
|
168
|
+
else
|
|
169
|
+
'border-left: 2px solid transparent'
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Render a sidebar entry's title row with its right-aligned detail.
|
|
174
|
+
#
|
|
175
|
+
# A String detail is wrapped in a muted span; any other truthy value is
|
|
176
|
+
# rendered as-is (e.g. prebuilt markup), and nil renders nothing.
|
|
177
|
+
#
|
|
178
|
+
# @param title [String] the entry title
|
|
179
|
+
# @param detail [String, ActiveSupport::SafeBuffer, nil] the detail text or markup
|
|
180
|
+
# @return [ActiveSupport::SafeBuffer] the title row markup
|
|
181
|
+
def sidebar_item_header(title, detail)
|
|
182
|
+
tag.div(class: 'flex items-baseline justify-between gap-2') do
|
|
183
|
+
tag.span(title, class: 'truncate text-xs font-semibold') + sidebar_item_detail(detail)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Render a sidebar entry's detail value.
|
|
188
|
+
#
|
|
189
|
+
# @param detail [String, ActiveSupport::SafeBuffer, nil] the detail text or markup
|
|
190
|
+
# @return [ActiveSupport::SafeBuffer] the detail markup, or an empty buffer
|
|
191
|
+
def sidebar_item_detail(detail)
|
|
192
|
+
if detail.is_a?(String)
|
|
193
|
+
return tag.span(detail, class: 'flex-shrink-0 text-xs',
|
|
194
|
+
style: 'color: var(--text-faint)')
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
detail || ''.html_safe
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Render a sidebar entry's subtitle line, or nothing.
|
|
201
|
+
#
|
|
202
|
+
# @param subtitle [String, nil] the subtitle text
|
|
203
|
+
# @return [ActiveSupport::SafeBuffer] the subtitle markup, or an empty buffer
|
|
204
|
+
def sidebar_item_subtitle(subtitle)
|
|
205
|
+
return ''.html_safe unless subtitle
|
|
206
|
+
|
|
207
|
+
tag.p(subtitle, class: 'mt-0.5 truncate text-xs', style: 'color: var(--text-muted)')
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# The simple data-table component: a header list + rows in, table markup out, with a built-in empty
|
|
6
|
+
# state so call sites never need their own empty-vs-populated branch. Theme colors come from CSS
|
|
7
|
+
# custom properties so the table tracks light/dark mode.
|
|
8
|
+
module TablesHelper
|
|
9
|
+
# A table's data model: column +headers+ and +rows+ (each row an array of cells). Built by view
|
|
10
|
+
# helpers and passed straight to +#table+, so a caller defines both halves in one place.
|
|
11
|
+
Table = Struct.new(:headers, :rows, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Render a simple data table from a header list and a list of rows.
|
|
14
|
+
#
|
|
15
|
+
# Each row is an array of cell values, positionally aligned to +headers+; cell values may be plain
|
|
16
|
+
# strings or already-rendered markup (e.g. a +badge+ or +destructive_link+), which is emitted as-is.
|
|
17
|
+
# Theme colors come from CSS custom properties so the table tracks light/dark mode.
|
|
18
|
+
#
|
|
19
|
+
# When +rows+ is empty the table renders a centered, muted +empty+ message in place of the body, so
|
|
20
|
+
# call sites never need their own empty-vs-populated branch.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# table(headers: ["Kind", "Pattern", ""], rows: rules.map { |r| [badge(r.kind), r.pattern, remove_link(r)] })
|
|
24
|
+
#
|
|
25
|
+
# @param headers [Array<String>] the column headers (use +""+ for an unlabeled column)
|
|
26
|
+
# @param rows [Array<Array>] the rows, each an array of cells aligned to +headers+
|
|
27
|
+
# @param empty [String] the message shown, centered, when there are no rows
|
|
28
|
+
# @return [ActiveSupport::SafeBuffer] the table markup
|
|
29
|
+
def table(headers:, rows:, empty: 'No results.')
|
|
30
|
+
return tag.p(empty, class: 'text-center text-sm py-6', style: 'color: var(--text-faint)') if rows.empty?
|
|
31
|
+
|
|
32
|
+
tag.table(class: 'w-full text-sm') do
|
|
33
|
+
tag.thead do
|
|
34
|
+
tag.tr do
|
|
35
|
+
safe_join(headers.map do |header|
|
|
36
|
+
tag.th(header, class: 'text-left font-medium pb-2 pr-4', style: 'color: var(--text-faint)')
|
|
37
|
+
end)
|
|
38
|
+
end
|
|
39
|
+
end +
|
|
40
|
+
tag.tbody do
|
|
41
|
+
safe_join(rows.map { |cells| table_row(cells) })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Render one +table+ body row from its cells, divided from the row above by a top border.
|
|
49
|
+
#
|
|
50
|
+
# @param cells [Array] the row's cell values, emitted in order
|
|
51
|
+
# @return [ActiveSupport::SafeBuffer] the +<tr>+ markup
|
|
52
|
+
def table_row(cells)
|
|
53
|
+
tag.tr(style: 'border-top: 1px solid var(--border)') do
|
|
54
|
+
safe_join(cells.map { |cell| tag.td(cell, class: 'py-3 pr-4 align-middle') })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Components
|
|
5
|
+
# Text primitives: headings, muted hints, and verbatim/monospace blocks. Theme colors come from CSS
|
|
6
|
+
# custom properties (--text-faint, …) so type tracks light/dark mode via the single +.dark+ toggle.
|
|
7
|
+
module TypographyHelper
|
|
8
|
+
# Render a level-1 heading.
|
|
9
|
+
#
|
|
10
|
+
# @param text [String] the heading text
|
|
11
|
+
# @return [ActiveSupport::SafeBuffer] the +<h1>+ markup
|
|
12
|
+
def heading_1(text)
|
|
13
|
+
tag.h1(text, class: 'text-lg font-semibold')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Render a level-2 heading styled as a muted, uppercase section label.
|
|
17
|
+
#
|
|
18
|
+
# @param text [String] the heading text
|
|
19
|
+
# @return [ActiveSupport::SafeBuffer] the +<h2>+ markup
|
|
20
|
+
def heading_2(text)
|
|
21
|
+
tag.h2(text, class: 'text-xs font-semibold uppercase tracking-wider', style: 'color: var(--text-faint)')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Render a level-3 heading.
|
|
25
|
+
#
|
|
26
|
+
# @param text [String] the heading text
|
|
27
|
+
# @return [ActiveSupport::SafeBuffer] the +<h3>+ markup
|
|
28
|
+
def heading_3(text)
|
|
29
|
+
tag.h3(text, class: 'text-sm font-semibold')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Render a line of muted helper text — a caption, hint, or inline empty-state note.
|
|
33
|
+
#
|
|
34
|
+
# @param text [String] the hint text
|
|
35
|
+
# @return [ActiveSupport::SafeBuffer] the muted paragraph markup
|
|
36
|
+
# @param [String] classes
|
|
37
|
+
def hint(text, classes: '')
|
|
38
|
+
tag.p(text, class: "text-xs #{classes}", style: 'color: var(--text-faint)')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Render a block of verbatim body text — whitespace preserved, monospace — for content shown
|
|
42
|
+
# as-is, such as an email body or a persona's instructions. Escapes its input.
|
|
43
|
+
#
|
|
44
|
+
# @param text [String] the text to render
|
|
45
|
+
# @return [ActiveSupport::SafeBuffer] the preformatted text block
|
|
46
|
+
def body_text(text)
|
|
47
|
+
tag.div(text, class: 'text-sm whitespace-pre-wrap font-mono')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Render a short code snippet as a boxed, monospace chip — a config line or command shown inline in
|
|
51
|
+
# a page (e.g. an empty state). Sizes to its content, matching the +badge+ surface/border styling.
|
|
52
|
+
# Click-to-copy: the +data-copy+ marker wires it to copy.js (copies the text on click) and the
|
|
53
|
+
# matching CSS rule shows the copy cursor on hover. Escapes its input.
|
|
54
|
+
#
|
|
55
|
+
# @param text [String] the code to display
|
|
56
|
+
# @return [ActiveSupport::SafeBuffer] the code-chip markup
|
|
57
|
+
def code_block(text)
|
|
58
|
+
tag.code(text, class: 'inline-block rounded-md px-3 py-1.5 text-xs font-mono break-all',
|
|
59
|
+
data: { copy: true },
|
|
60
|
+
style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# View helpers for email-domain pages and the domains sidebar.
|
|
5
|
+
module EmailDomainsHelper
|
|
6
|
+
# Fetch every +EmailDomain+ ordered by name for sidebar listing.
|
|
7
|
+
#
|
|
8
|
+
# @return [ActiveRecord::Relation<Protege::EmailDomain>] domains ordered alphabetically
|
|
9
|
+
def all_email_domains
|
|
10
|
+
EmailDomain.order(:domain)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Whether Protege is running the self-hosted MTA (as opposed to an email provider). Keys off the
|
|
14
|
+
# existing accessory setting via +Gateway.mail_accessory_host+ — no separate flag. Drives the
|
|
15
|
+
# mode-aware domain page: only self-hosting uses +EmailDomain+'s own DKIM key and can push to an MTA.
|
|
16
|
+
#
|
|
17
|
+
# @return [Boolean] true when a mail accessory is configured
|
|
18
|
+
def mail_self_hosted?
|
|
19
|
+
Gateway.mail_accessory_host.present?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|