shiplog 0.1.0
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 +15 -0
- data/MIT-LICENSE +20 -0
- data/README.md +410 -0
- data/app/controllers/shiplog/admin/application_controller.rb +37 -0
- data/app/controllers/shiplog/admin/boards_controller.rb +60 -0
- data/app/controllers/shiplog/admin/entries/approvals_controller.rb +15 -0
- data/app/controllers/shiplog/admin/entries/discards_controller.rb +25 -0
- data/app/controllers/shiplog/admin/entries/rewrites_controller.rb +13 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +55 -0
- data/app/controllers/shiplog/admin/syncs_controller.rb +21 -0
- data/app/controllers/shiplog/application_controller.rb +5 -0
- data/app/controllers/shiplog/changelogs_controller.rb +24 -0
- data/app/controllers/shiplog/frontends_controller.rb +21 -0
- data/app/controllers/shiplog/public_controller.rb +19 -0
- data/app/controllers/shiplog/roadmaps_controller.rb +14 -0
- data/app/controllers/shiplog/webhooks_controller.rb +121 -0
- data/app/frontend/shiplog/admin.css +137 -0
- data/app/frontend/shiplog/board.js +77 -0
- data/app/frontend/shiplog/shiplog.css +184 -0
- data/app/helpers/shiplog/admin/entries_helper.rb +27 -0
- data/app/helpers/shiplog/application_helper.rb +14 -0
- data/app/helpers/shiplog/frontend_helper.rb +17 -0
- data/app/helpers/shiplog/host_routes_helper.rb +25 -0
- data/app/helpers/shiplog/styling_helper.rb +24 -0
- data/app/jobs/shiplog/application_job.rb +14 -0
- data/app/jobs/shiplog/refresh_job.rb +38 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +9 -0
- data/app/jobs/shiplog/scan_changes_job.rb +36 -0
- data/app/jobs/shiplog/sync_job.rb +20 -0
- data/app/models/shiplog/application_record.rb +5 -0
- data/app/models/shiplog/board_arrangement.rb +38 -0
- data/app/models/shiplog/change_candidates.rb +77 -0
- data/app/models/shiplog/commit.rb +9 -0
- data/app/models/shiplog/drafter.rb +69 -0
- data/app/models/shiplog/entry.rb +42 -0
- data/app/models/shiplog/issue.rb +68 -0
- data/app/models/shiplog/issue_form.rb +43 -0
- data/app/models/shiplog/issue_link.rb +6 -0
- data/app/models/shiplog/pull_request.rb +11 -0
- data/app/models/shiplog/repository.rb +9 -0
- data/app/models/shiplog/rewriter/prompt.rb +71 -0
- data/app/models/shiplog/rewriter.rb +89 -0
- data/app/models/shiplog/roadmap/column_resolver.rb +46 -0
- data/app/models/shiplog/roadmap.rb +34 -0
- data/app/models/shiplog/sync/commit_scanner.rb +39 -0
- data/app/models/shiplog/sync/issue_importer.rb +50 -0
- data/app/models/shiplog/sync/node_attributes.rb +50 -0
- data/app/models/shiplog/sync/pull_request_importer.rb +54 -0
- data/app/models/shiplog/sync/repository_sync.rb +80 -0
- data/app/models/shiplog/webhook_delivery.rb +5 -0
- data/app/models/shiplog/webhook_signature.rb +16 -0
- data/app/views/layouts/shiplog/admin.html.erb +16 -0
- data/app/views/layouts/shiplog/application.html.erb +15 -0
- data/app/views/shiplog/_empty.html.erb +3 -0
- data/app/views/shiplog/_page.html.erb +6 -0
- data/app/views/shiplog/_page_header.html.erb +6 -0
- data/app/views/shiplog/_pagination.html.erb +12 -0
- data/app/views/shiplog/admin/_assets.html.erb +3 -0
- data/app/views/shiplog/admin/_custom_head.html.erb +0 -0
- data/app/views/shiplog/admin/_nav.html.erb +9 -0
- data/app/views/shiplog/admin/_page.html.erb +14 -0
- data/app/views/shiplog/admin/boards/_card.html.erb +22 -0
- data/app/views/shiplog/admin/boards/show.html.erb +17 -0
- data/app/views/shiplog/admin/entries/_badges.html.erb +10 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +21 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +61 -0
- data/app/views/shiplog/admin/entries/index.html.erb +47 -0
- data/app/views/shiplog/admin/syncs/show.html.erb +71 -0
- data/app/views/shiplog/changelogs/_entry.html.erb +8 -0
- data/app/views/shiplog/changelogs/_month.html.erb +9 -0
- data/app/views/shiplog/changelogs/show.html.erb +16 -0
- data/app/views/shiplog/roadmaps/_card.html.erb +6 -0
- data/app/views/shiplog/roadmaps/_column.html.erb +17 -0
- data/app/views/shiplog/roadmaps/show.html.erb +9 -0
- data/config/admin_routes.rb +13 -0
- data/config/locales/en.yml +106 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20261026000001_create_shiplog_tables.rb +102 -0
- data/lib/generators/shiplog/install/install_generator.rb +94 -0
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +86 -0
- data/lib/generators/shiplog/install/templates/shiplog-feature.yml +23 -0
- data/lib/shiplog/admin/engine.rb +9 -0
- data/lib/shiplog/classes.rb +38 -0
- data/lib/shiplog/configuration.rb +92 -0
- data/lib/shiplog/engine.rb +5 -0
- data/lib/shiplog/github/client.rb +96 -0
- data/lib/shiplog/github/errors.rb +17 -0
- data/lib/shiplog/github/queries.rb +108 -0
- data/lib/shiplog/version.rb +3 -0
- data/lib/shiplog.rb +16 -0
- metadata +154 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
class EntriesController < ApplicationController
|
|
4
|
+
PER_PAGE = 50
|
|
5
|
+
MAX_PAGE = 100_000
|
|
6
|
+
|
|
7
|
+
before_action :set_entry, only: %i[edit update]
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@status = Entry.statuses.key(params[:status].to_s) || "draft"
|
|
11
|
+
@kind = Entry.kinds.key(params[:kind].to_s)
|
|
12
|
+
@page = page_number
|
|
13
|
+
scope = Entry.where(status: @status)
|
|
14
|
+
scope = scope.where(kind: @kind) if @kind
|
|
15
|
+
records = scope.includes(:subject).order(updated_at: :desc, id: :desc)
|
|
16
|
+
.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
17
|
+
@more = records.size > PER_PAGE
|
|
18
|
+
@entries = records.first(PER_PAGE)
|
|
19
|
+
preload_repositories(@entries)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def edit
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update
|
|
26
|
+
if @entry.update(entry_params)
|
|
27
|
+
redirect_to root_path, notice: t("shiplog.admin.entries.saved")
|
|
28
|
+
else
|
|
29
|
+
render :edit, status: :unprocessable_content
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Every subject type (issue, pull request, commit) belongs to a repository; load them once per type.
|
|
36
|
+
def preload_repositories(entries)
|
|
37
|
+
entries.filter_map(&:subject).group_by(&:class).each_value do |subjects|
|
|
38
|
+
ActiveRecord::Associations::Preloader.new(records: subjects, associations: :repository).call
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_entry
|
|
43
|
+
@entry = Entry.find(params[:id])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def entry_params
|
|
47
|
+
params.expect(entry: %i[title body released_on])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def page_number
|
|
51
|
+
Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
class SyncsController < ApplicationController
|
|
4
|
+
DELIVERIES_SHOWN = 20
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
repositories = Repository.where(full_name: Shiplog.config.repositories).index_by(&:full_name)
|
|
8
|
+
@repositories = Shiplog.config.repositories.map { |full_name| [ full_name, repositories[full_name] ] }
|
|
9
|
+
@deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
|
|
10
|
+
@webhook_secret_missing = Shiplog.config.webhook_secret.blank?
|
|
11
|
+
@github_token_missing = Shiplog.config.resolved_github_token.blank?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
SyncJob.perform_later
|
|
16
|
+
ScanChangesJob.perform_later if params[:scan] == "1"
|
|
17
|
+
redirect_to sync_path, notice: t("shiplog.admin.sync.queued")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
class ChangelogsController < PublicController
|
|
3
|
+
PER_PAGE = 30
|
|
4
|
+
MAX_PAGE = 100_000
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
@page = page_number
|
|
8
|
+
scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
|
|
9
|
+
.order(released_on: :desc, approved_at: :desc, id: :desc)
|
|
10
|
+
records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
11
|
+
return head(:not_found) if @page > 1 && records.empty?
|
|
12
|
+
|
|
13
|
+
@older = records.size > PER_PAGE
|
|
14
|
+
@months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
|
|
15
|
+
fresh_when etag: [ *shiplog_etag_parts, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def page_number
|
|
21
|
+
Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
class FrontendsController < ApplicationController
|
|
3
|
+
ASSET_DIRECTORY = Engine.root.join("app/frontend/shiplog")
|
|
4
|
+
ASSETS = {
|
|
5
|
+
"shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
|
|
6
|
+
"admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
|
|
7
|
+
"board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" }
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
# The version segment busts caches; stale versions are 404 so they are never cached as the current file.
|
|
11
|
+
skip_after_action :verify_same_origin_request, raise: false
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
return head(:not_found) unless params[:version] == Shiplog::VERSION
|
|
15
|
+
|
|
16
|
+
asset = ASSETS[params[:name].to_s] or return head(:not_found)
|
|
17
|
+
expires_in 1.year, public: true, immutable: true
|
|
18
|
+
send_file asset[:path], type: asset[:type], disposition: :inline
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Parent controller for the public pages. The parent class is resolved when this
|
|
3
|
+
# file loads, so `config.public_parent_controller` must be set in an initializer
|
|
4
|
+
# (config/initializers/shiplog.rb), not later at runtime.
|
|
5
|
+
class PublicController < Shiplog.config.public_parent_controller.constantize
|
|
6
|
+
helper Shiplog::FrontendHelper
|
|
7
|
+
helper Shiplog::StylingHelper
|
|
8
|
+
helper Shiplog::HostRoutesHelper
|
|
9
|
+
layout -> { Shiplog.config.public_layout }
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# What the rendered markup depends on besides the data: the locale, the gem version and the styling
|
|
14
|
+
# config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
|
|
15
|
+
def shiplog_etag_parts
|
|
16
|
+
[ I18n.locale, Shiplog::VERSION, Shiplog.config.classes.hash, Shiplog.config.stylesheet ]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
class RoadmapsController < PublicController
|
|
3
|
+
def show
|
|
4
|
+
@columns = Roadmap.new(published_only: true).columns
|
|
5
|
+
fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns) ]
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def etag_for(columns)
|
|
11
|
+
columns.transform_values { |items| items.map { |item| [ item.entry.id, item.entry.updated_at ] } }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
class WebhooksController < ActionController::Base
|
|
3
|
+
MAX_BODY_BYTES = 25.megabytes
|
|
4
|
+
|
|
5
|
+
skip_forgery_protection
|
|
6
|
+
layout false
|
|
7
|
+
|
|
8
|
+
before_action :disable_session
|
|
9
|
+
before_action :read_body_within_limit
|
|
10
|
+
before_action :require_secret
|
|
11
|
+
before_action :verify_signature
|
|
12
|
+
before_action :require_delivery_id
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
payload = parse_payload or return head(:bad_request)
|
|
16
|
+
event = request.headers["X-GitHub-Event"].to_s
|
|
17
|
+
return head(:ok) if event == "ping"
|
|
18
|
+
|
|
19
|
+
delivery = record_delivery(event, payload) or return head(:accepted)
|
|
20
|
+
enqueue_refreshes(delivery, event, payload)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def disable_session
|
|
26
|
+
request.session_options[:skip] = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Reads the body once, never more than the cap plus one byte; the result is reused for signature and parsing.
|
|
30
|
+
def read_body_within_limit
|
|
31
|
+
return head :content_too_large if request.content_length.to_i > MAX_BODY_BYTES
|
|
32
|
+
|
|
33
|
+
# Content-Length may be absent (chunked) or wrong; check what was actually received.
|
|
34
|
+
@body = request.body.read(MAX_BODY_BYTES + 1).to_s
|
|
35
|
+
request.body.rewind
|
|
36
|
+
head :content_too_large if @body.bytesize > MAX_BODY_BYTES
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def require_delivery_id
|
|
40
|
+
head :bad_request if delivery_id.blank?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delivery_id
|
|
44
|
+
request.headers["X-GitHub-Delivery"].to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def require_secret
|
|
48
|
+
head :service_unavailable if Shiplog.config.webhook_secret.blank?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify_signature
|
|
52
|
+
signature = WebhookSignature.new(
|
|
53
|
+
secret: Shiplog.config.webhook_secret.to_s,
|
|
54
|
+
body: @body,
|
|
55
|
+
header: request.headers["X-Hub-Signature-256"]
|
|
56
|
+
)
|
|
57
|
+
head :unauthorized unless signature.valid?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_payload
|
|
61
|
+
payload = JSON.parse(@body)
|
|
62
|
+
payload if payload.is_a?(Hash)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the delivery to process: a new one, or an earlier one that failed (error, never processed).
|
|
68
|
+
def record_delivery(event, payload)
|
|
69
|
+
WebhookDelivery.create!(
|
|
70
|
+
delivery_id: delivery_id,
|
|
71
|
+
event: event,
|
|
72
|
+
action: payload["action"],
|
|
73
|
+
repository_full_name: payload.dig("repository", "full_name"),
|
|
74
|
+
received_at: Time.current
|
|
75
|
+
)
|
|
76
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
77
|
+
retry_failed_delivery
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Claims atomically (clearing the error) so concurrent redeliveries do not both reprocess.
|
|
81
|
+
def retry_failed_delivery
|
|
82
|
+
failed = WebhookDelivery.where(delivery_id: delivery_id, processed_at: nil).where.not(error: nil)
|
|
83
|
+
delivery = failed.first
|
|
84
|
+
# Known gap: if the process dies after this claim but before processed_at is set, the delivery stays stuck;
|
|
85
|
+
# the scheduled SyncJob repairs the data.
|
|
86
|
+
delivery if delivery && failed.where(id: delivery.id).update_all(error: nil, updated_at: Time.current) == 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def enqueue_refreshes(delivery, event, payload)
|
|
90
|
+
refresh_targets(event, payload).each { |target| RefreshJob.perform_later(*target) }
|
|
91
|
+
delivery.update!(processed_at: Time.current)
|
|
92
|
+
head :accepted
|
|
93
|
+
rescue => error
|
|
94
|
+
delivery.update(error: "#{error.class}: #{error.message}")
|
|
95
|
+
head :internal_server_error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def refresh_targets(event, payload)
|
|
99
|
+
targets =
|
|
100
|
+
case event
|
|
101
|
+
when "issues" then [ [ payload.dig("repository", "full_name"), "issue", payload.dig("issue", "number") ] ]
|
|
102
|
+
when "pull_request" then [ [ payload.dig("repository", "full_name"), "pull_request", payload.dig("pull_request", "number") ] ]
|
|
103
|
+
when "sub_issues" then sub_issue_targets(payload)
|
|
104
|
+
else []
|
|
105
|
+
end
|
|
106
|
+
targets.filter_map { |repo, kind, number| [ Shiplog.config.repository_configured?(repo), kind, number ] if number }
|
|
107
|
+
.select(&:first).uniq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def sub_issue_targets(payload)
|
|
111
|
+
here = payload.dig("repository", "full_name")
|
|
112
|
+
if payload["action"].to_s.start_with?("parent_issue")
|
|
113
|
+
[ [ here, "issue", payload.dig("sub_issue", "number") ],
|
|
114
|
+
[ payload.dig("parent_issue_repo", "full_name"), "issue", payload.dig("parent_issue", "number") ] ]
|
|
115
|
+
else
|
|
116
|
+
[ [ here, "issue", payload.dig("parent_issue", "number") ],
|
|
117
|
+
[ payload.dig("sub_issue_repo", "full_name"), "issue", payload.dig("sub_issue", "number") ] ]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shiplog admin. Everything lives in the `shiplog` cascade layer and is scoped under .shiplog-admin,
|
|
3
|
+
* so it neither leaks into nor is unexpectedly overridden by a host admin. Unlayered host CSS wins.
|
|
4
|
+
*
|
|
5
|
+
* Inside a host layout the admin inherits the host's text colour and uses surfaces and borders mixed
|
|
6
|
+
* from currentColor, so it suits light and dark hosts. Only the standalone chrome
|
|
7
|
+
* (`.shiplog-admin--standalone`), which paints the page background, follows prefers-color-scheme.
|
|
8
|
+
*
|
|
9
|
+
* Variables (set them on :root or .shiplog-admin):
|
|
10
|
+
* --shiplog-admin-fg, -muted, -bg, -surface, -border, -accent
|
|
11
|
+
*/
|
|
12
|
+
@layer shiplog {
|
|
13
|
+
/* Host-safe defaults. */
|
|
14
|
+
.shiplog-admin {
|
|
15
|
+
--sa-fg: var(--shiplog-admin-fg, currentColor);
|
|
16
|
+
--sa-muted: var(--shiplog-admin-muted, color-mix(in srgb, currentColor 65%, transparent));
|
|
17
|
+
--sa-bg: var(--shiplog-admin-bg, color-mix(in srgb, currentColor 6%, transparent));
|
|
18
|
+
--sa-surface: var(--shiplog-admin-surface, transparent);
|
|
19
|
+
--sa-border: var(--shiplog-admin-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
20
|
+
--sa-accent: var(--shiplog-admin-accent, #2f81f7);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The standalone chrome owns the page background, so it picks its own light and dark palette. */
|
|
24
|
+
.shiplog-admin.shiplog-admin--standalone {
|
|
25
|
+
--sa-fg: var(--shiplog-admin-fg, #1f2328);
|
|
26
|
+
--sa-muted: var(--shiplog-admin-muted, #59636e);
|
|
27
|
+
--sa-bg: var(--shiplog-admin-bg, #f6f8fa);
|
|
28
|
+
--sa-surface: var(--shiplog-admin-surface, #ffffff);
|
|
29
|
+
--sa-border: var(--shiplog-admin-border, #d1d9e0);
|
|
30
|
+
--sa-accent: var(--shiplog-admin-accent, #0969da);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
.shiplog-admin.shiplog-admin--standalone {
|
|
35
|
+
--sa-fg: var(--shiplog-admin-fg, #e6edf3);
|
|
36
|
+
--sa-muted: var(--shiplog-admin-muted, #9198a1);
|
|
37
|
+
--sa-bg: var(--shiplog-admin-bg, #0d1117);
|
|
38
|
+
--sa-surface: var(--shiplog-admin-surface, #151b23);
|
|
39
|
+
--sa-border: var(--shiplog-admin-border, #3d444d);
|
|
40
|
+
--sa-accent: var(--shiplog-admin-accent, #4493f8);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.shiplog-admin {
|
|
45
|
+
color: var(--sa-fg);
|
|
46
|
+
line-height: 1.5;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Only the engine's own layout paints the page; inside a host layout the host owns background and font. */
|
|
50
|
+
.shiplog-admin--standalone {
|
|
51
|
+
background: var(--sa-bg);
|
|
52
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
53
|
+
min-height: 100vh;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.shiplog-admin a { color: var(--sa-accent); }
|
|
57
|
+
|
|
58
|
+
.shiplog-admin .shiplog-admin-nav {
|
|
59
|
+
background: var(--sa-surface);
|
|
60
|
+
border-bottom: 1px solid var(--sa-border);
|
|
61
|
+
padding: 0.75rem 1rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.shiplog-admin .shiplog-admin-nav--sub { background: none; border-bottom: 0; padding: 0 0 0.75rem; }
|
|
65
|
+
|
|
66
|
+
.shiplog-admin .shiplog-admin-nav__list { display: flex; gap: 1.25rem; list-style: none; margin: 0; padding: 0; }
|
|
67
|
+
|
|
68
|
+
.shiplog-admin .shiplog-admin__main { margin: 0 auto; max-width: 80rem; padding: 1.5rem 1rem; }
|
|
69
|
+
|
|
70
|
+
.shiplog-admin .shiplog-admin__flash { border: 1px solid var(--sa-border); border-radius: 0.375rem; padding: 0.5rem 0.75rem; }
|
|
71
|
+
.shiplog-admin .shiplog-admin__flash--alert { border-color: #cf222e; }
|
|
72
|
+
|
|
73
|
+
.shiplog-admin .shiplog-admin__title { font-size: 1.5rem; margin: 0 0 1rem; }
|
|
74
|
+
.shiplog-admin .shiplog-admin__muted { color: var(--sa-muted); font-size: 0.875rem; }
|
|
75
|
+
.shiplog-admin .shiplog-admin__empty { color: var(--sa-muted); }
|
|
76
|
+
|
|
77
|
+
.shiplog-admin .shiplog-admin-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 2rem; margin-bottom: 1rem; }
|
|
78
|
+
.shiplog-admin .shiplog-admin-filters__list { display: flex; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
|
|
79
|
+
.shiplog-admin .shiplog-admin-filters__link[aria-current] { color: var(--sa-fg); font-weight: 600; text-decoration: none; }
|
|
80
|
+
|
|
81
|
+
.shiplog-admin .shiplog-admin-table { background: var(--sa-surface); border: 1px solid var(--sa-border); border-collapse: collapse; width: 100%; }
|
|
82
|
+
.shiplog-admin .shiplog-admin-table th,
|
|
83
|
+
.shiplog-admin .shiplog-admin-table td { border-bottom: 1px solid var(--sa-border); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
|
|
84
|
+
.shiplog-admin .shiplog-admin-table__actions { display: flex; gap: 0.5rem; }
|
|
85
|
+
.shiplog-admin .shiplog-admin-table__actions form { margin: 0; }
|
|
86
|
+
|
|
87
|
+
.shiplog-admin .shiplog-admin-pagination { display: flex; justify-content: space-between; margin-top: 1rem; }
|
|
88
|
+
.shiplog-admin .shiplog-admin-pagination__next { margin-left: auto; }
|
|
89
|
+
|
|
90
|
+
.shiplog-admin .shiplog-badge {
|
|
91
|
+
background: var(--sa-bg);
|
|
92
|
+
border: 1px solid var(--sa-border);
|
|
93
|
+
border-radius: 1rem;
|
|
94
|
+
font-size: 0.75rem;
|
|
95
|
+
padding: 0.0625rem 0.5rem;
|
|
96
|
+
}
|
|
97
|
+
.shiplog-admin .shiplog-badge--warning { border-color: #bf8700; color: #9a6700; }
|
|
98
|
+
.shiplog-admin .shiplog-badge--error { border-color: #cf222e; color: #cf222e; }
|
|
99
|
+
|
|
100
|
+
.shiplog-admin .shiplog-button {
|
|
101
|
+
background: var(--sa-surface);
|
|
102
|
+
border: 1px solid var(--sa-border);
|
|
103
|
+
border-radius: 0.375rem;
|
|
104
|
+
color: var(--sa-fg);
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
display: inline-block;
|
|
107
|
+
font: inherit;
|
|
108
|
+
padding: 0.25rem 0.75rem;
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
}
|
|
111
|
+
.shiplog-admin .shiplog-button--primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
|
|
112
|
+
|
|
113
|
+
.shiplog-admin .shiplog-admin-split { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
|
|
114
|
+
.shiplog-admin .shiplog-admin-panel { background: var(--sa-surface); border: 1px solid var(--sa-border); border-radius: 0.5rem; padding: 1rem; }
|
|
115
|
+
.shiplog-admin .shiplog-admin-panel h2 { font-size: 1.125rem; margin-top: 0; }
|
|
116
|
+
.shiplog-admin .shiplog-admin-form label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
|
|
117
|
+
.shiplog-admin .shiplog-admin-form input[type=text],
|
|
118
|
+
.shiplog-admin .shiplog-admin-form textarea { box-sizing: border-box; font: inherit; width: 100%; }
|
|
119
|
+
.shiplog-admin .shiplog-admin-actions { border-top: 1px solid var(--sa-border); display: grid; gap: 0.75rem; margin-top: 1rem; padding-top: 1rem; }
|
|
120
|
+
.shiplog-admin .shiplog-admin-inline-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
121
|
+
.shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
|
|
122
|
+
|
|
123
|
+
.shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|
|
124
|
+
.shiplog-admin .shiplog-board__title { font-size: 1.125rem; margin: 0 0 0.5rem; }
|
|
125
|
+
.shiplog-admin .shiplog-board__column { display: flex; flex-direction: column; }
|
|
126
|
+
.shiplog-admin .shiplog-board__column .shiplog-board__cards { flex: 1; }
|
|
127
|
+
.shiplog-admin .shiplog-board__cards { display: grid; align-content: start; gap: 0.5rem; min-height: 6rem; padding: 0.5rem; background: var(--sa-bg); border: 1px dashed var(--sa-border); border-radius: 0.5rem; }
|
|
128
|
+
.shiplog-admin .shiplog-card { background: var(--sa-surface); border: 1px solid var(--sa-border); border-radius: 0.375rem; cursor: grab; padding: 0.5rem 0.75rem; }
|
|
129
|
+
.shiplog-admin .shiplog-card--dragging { opacity: 0.5; }
|
|
130
|
+
.shiplog-admin .shiplog-card__title { font-size: 0.9375rem; margin: 0 0 0.25rem; }
|
|
131
|
+
.shiplog-admin .shiplog-card__meta { color: var(--sa-muted); font-size: 0.8125rem; margin: 0 0 0.5rem; }
|
|
132
|
+
.shiplog-admin .shiplog-card__form { align-items: center; display: flex; gap: 0.375rem; margin-bottom: 0.25rem; }
|
|
133
|
+
.shiplog-admin .shiplog-card__label { font-size: 0.8125rem; }
|
|
134
|
+
|
|
135
|
+
/* The engine layout's own body (not scoped: it is not inside .shiplog-admin). */
|
|
136
|
+
.shiplog-admin-body { margin: 0; }
|
|
137
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const board = document.querySelector("[data-board-url]")
|
|
2
|
+
|
|
3
|
+
if (board) {
|
|
4
|
+
// Host layouts may not render csrf_meta_tags, so the board element carries the token as a fallback.
|
|
5
|
+
const csrfToken = document.querySelector("meta[name=csrf-token]")?.content ?? board.dataset.csrfToken
|
|
6
|
+
let dragged = null
|
|
7
|
+
let origin = null
|
|
8
|
+
let dropped = false
|
|
9
|
+
|
|
10
|
+
// The whole column section (heading included) is a drop target; cards live in its [data-column] list.
|
|
11
|
+
const listFor = (target) => target.closest?.("section")?.querySelector("[data-column]") ?? null
|
|
12
|
+
|
|
13
|
+
const save = async (list, card) => {
|
|
14
|
+
const issueIds = [...list.querySelectorAll(".shiplog-card")].map((el) => Number(el.dataset.issueId))
|
|
15
|
+
const response = await fetch(board.dataset.boardUrl, {
|
|
16
|
+
method: "PATCH",
|
|
17
|
+
headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken },
|
|
18
|
+
body: JSON.stringify({ column: list.dataset.column, moved_id: Number(card.dataset.issueId), issue_ids: issueIds })
|
|
19
|
+
})
|
|
20
|
+
if (!response.ok) return window.location.reload()
|
|
21
|
+
|
|
22
|
+
// The server renders the moved card as a reload would (Pinned badge, column select, Unpin).
|
|
23
|
+
const { card: html } = await response.json()
|
|
24
|
+
const template = document.createElement("template")
|
|
25
|
+
template.innerHTML = html.trim()
|
|
26
|
+
card.replaceWith(template.content.firstElementChild)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cardAfter = (list, y) => {
|
|
30
|
+
const cards = [...list.querySelectorAll(".shiplog-card:not(.shiplog-card--dragging)")]
|
|
31
|
+
return cards.find((card) => {
|
|
32
|
+
const box = card.getBoundingClientRect()
|
|
33
|
+
return y < box.top + box.height / 2
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const restore = () => {
|
|
38
|
+
const { parent, nextSibling } = origin
|
|
39
|
+
parent.insertBefore(dragged, nextSibling?.parentNode === parent ? nextSibling : null)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
board.addEventListener("dragstart", (event) => {
|
|
43
|
+
const card = event.target.closest?.(".shiplog-card")
|
|
44
|
+
if (!card) return
|
|
45
|
+
dragged = card
|
|
46
|
+
origin = { parent: card.parentNode, nextSibling: card.nextSibling }
|
|
47
|
+
dropped = false
|
|
48
|
+
event.dataTransfer.setData("text/plain", card.dataset.issueId)
|
|
49
|
+
event.dataTransfer.effectAllowed = "move"
|
|
50
|
+
card.classList.add("shiplog-card--dragging")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
board.addEventListener("dragend", () => {
|
|
54
|
+
if (!dragged) return
|
|
55
|
+
dragged.classList.remove("shiplog-card--dragging")
|
|
56
|
+
if (!dropped) restore()
|
|
57
|
+
dragged = null
|
|
58
|
+
origin = null
|
|
59
|
+
dropped = false
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
board.addEventListener("dragover", (event) => {
|
|
63
|
+
const list = listFor(event.target)
|
|
64
|
+
if (!dragged || !list) return
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
event.dataTransfer.dropEffect = "move"
|
|
67
|
+
list.insertBefore(dragged, cardAfter(list, event.clientY) ?? null)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
board.addEventListener("drop", (event) => {
|
|
71
|
+
const list = listFor(event.target)
|
|
72
|
+
if (!dragged || !list) return
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
dropped = true
|
|
75
|
+
save(list, dragged).catch(() => window.location.reload())
|
|
76
|
+
})
|
|
77
|
+
}
|