Initial commit: Tonalmine, a Material Design 3 theme for Redmine 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
surtic86
2026-10-01 06:05:29 +02:00
co-authored by Claude Opus 5.5
commit 06cd1db99c
47 changed files with 4302 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
services:
redmine:
image: redmine:7.0.1
ports:
- "3070:3000"
volumes:
- redmine-sqlite:/usr/src/redmine/sqlite
- redmine-files:/usr/src/redmine/files
- ../tonalmine:/usr/src/redmine/themes/tonalmine
- .:/seed:ro
volumes:
redmine-sqlite:
redmine-files:
+201
View File
@@ -0,0 +1,201 @@
# Demo data for visual theme testing.
# Run: docker compose exec redmine bin/rails runner /seed/seed.rb
# Idempotent: exits if the demo project already exists. Dates are relative to today
# so Gantt, calendar and roadmap always show current data.
if Project.exists?(identifier: 'apollo')
puts 'Demo data already present.'
load File.join(__dir__, 'seed_extras.rb')
exit
end
srand(42)
today = Date.today
# One transaction: a failure leaves no partial data.
ActiveRecord::Base.transaction do
Redmine::DefaultData::Loader.load('en') if Redmine::DefaultData::Loader.no_data?
Setting.ui_theme = 'tonalmine'
Setting.text_formatting = 'common_mark'
Setting.notified_events = [] # demo data must not send mail
admin = User.find_by(login: 'admin')
admin.update!(must_change_passwd: false)
User.current = admin
people = [
%w[jdoe Jane Doe], %w[msmith Max Smith], %w[lnguyen Linh Nguyen], %w[pweber Paula Weber]
].map do |login, first, last|
User.create!(login: login, firstname: first, lastname: last, mail: "#{login}@example.com",
password: 'password123', password_confirmation: 'password123', language: 'en')
end
team = Group.create!(lastname: 'Developers')
team.users << people[0..2]
manager = Role.find_by!(name: 'Manager')
developer = Role.find_by!(name: 'Developer')
reporter = Role.find_by!(name: 'Reporter')
customer_field = IssueCustomField.create!(name: 'Customer', field_format: 'list', possible_values: %w[Acme Globex Initech],
is_for_all: true, is_filter: true, tracker_ids: Tracker.ids)
review_field = IssueCustomField.create!(name: 'Review date', field_format: 'date', is_for_all: true,
is_filter: true, tracker_ids: Tracker.ids)
modules = Redmine::AccessControl.available_project_modules.map(&:to_s)
make_project = lambda do |name, identifier, parent, description|
Project.create!(name: name, identifier: identifier, description: description, is_public: true,
parent: parent, tracker_ids: Tracker.ids, enabled_module_names: modules)
end
apollo = make_project.('Apollo', 'apollo', nil, "Customer portal relaunch.\n\n**Goal:** ship v2.0 this quarter.")
web = make_project.('Apollo Web', 'apollo-web', apollo, 'Frontend of the customer portal.')
api = make_project.('Apollo API', 'apollo-api', apollo, 'REST backend.')
infra = make_project.('Infrastructure', 'infra', nil, 'Servers, CI and monitoring.')
projects = [apollo, web, api, infra]
projects.each do |p|
Member.create!(project: p, principal: admin, role_ids: [manager.id])
Member.create!(project: p, principal: people[0], role_ids: [manager.id])
Member.create!(project: p, principal: team, role_ids: [developer.id])
Member.create!(project: p, principal: people[3], role_ids: [reporter.id])
%w[Frontend Backend Docs].each { |c| IssueCategory.create!(project: p, name: c) }
end
v1 = Version.create!(project: apollo, name: '1.0', effective_date: today - 60, sharing: 'descendants')
v2 = Version.create!(project: apollo, name: '2.0', effective_date: today + 14, sharing: 'descendants',
description: 'Portal relaunch')
v3 = Version.create!(project: apollo, name: '2.1', effective_date: today + 60, sharing: 'descendants')
vi = Version.create!(project: infra, name: 'Q4 hardening', effective_date: today + 30)
titles = [
'Login page shows wrong error message', 'Add CSV export to reports', 'Upgrade database driver',
'Dashboard loads slowly with many widgets', 'Password reset mail not localized', 'Add dark mode',
'Refactor session handling', 'Document REST authentication', 'Search ignores umlauts',
'Broken layout on small screens', 'Add audit log', 'Rate limit API endpoints', 'Improve onboarding tour',
'Fix timezone in due dates', 'Add SSO via OIDC', 'Remove legacy XML endpoints', 'Paginate project list',
'Cache avatar images', 'Nightly backup verification', 'Migrate CI to new runners'
]
open_statuses = IssueStatus.sorted.reject(&:is_closed?)
closed_status = IssueStatus.sorted.find(&:is_closed?)
priorities = IssuePriority.active.to_a
assignees = people + [admin, nil]
issues = Array.new(60) do |i|
project = projects[i % projects.size]
version = project == infra ? vi : [v1, v2, v2, v3, nil][i % 5]
status = version == v1 || i % 9 == 7 ? closed_status : open_statuses[i % open_statuses.size]
start = today + rand(-40..20)
suffix = i >= titles.size ? " (#{i / titles.size + 1})" : ''
issue = Issue.new(project: project, tracker: project.trackers.to_a[i % project.trackers.size],
author: people[i % people.size], subject: titles[i % titles.size] + suffix,
description: "Steps to reproduce:\n\n1. Open the page\n2. Click **Save**\n3. See the error\n\n```ruby\nputs 'hello'\n```",
priority: priorities[i % priorities.size], assigned_to: assignees[i % assignees.size],
category: project.issue_categories.to_a[i % 3], fixed_version: version,
start_date: start, due_date: start + rand(3..30), estimated_hours: [2, 4, 8, 16, nil][i % 5],
done_ratio: status.is_closed? ? 100 : [0, 10, 30, 50, 80][i % 5], is_private: i == 5)
issue.status = status
issue.custom_field_values = { customer_field.id => %w[Acme Globex Initech][i % 3], review_field.id => (today + i).to_s }
issue.save!
issue
end
v1.update!(status: 'closed') # closed versions reject new issues, so close it afterwards
# Subtasks and relations (same project: cross-project relations are off by default)
showcase = issues[12] # open apollo issue: gets subtasks, attachments and watchers
[issues[24], issues[28]].each { |child| child.reload.update!(parent_issue_id: showcase.id) }
IssueRelation.create!(issue_from: showcase, issue_to: issues[32], relation_type: 'relates')
IssueRelation.create!(issue_from: issues[9], issue_to: issues[13], relation_type: 'blocks')
IssueRelation.create!(issue_from: issues[14], issue_to: issues[18], relation_type: 'precedes')
# Journals: notes and attribute changes
issues.first(15).each_with_index do |issue, i|
issue.reload.init_journal(people[i % 3], "Looked into this. #{i.even? ? 'Needs a decision from **product**.' : 'Fix is in review.'}")
issue.done_ratio = [issue.done_ratio.to_i + 10, 100].min unless issue.closed?
issue.save!
end
showcase.watcher_users << people[1..2]
# Attachments
# 160x90 solid-color RGB PNG, built by hand so the seed needs no image file
png_chunk = ->(type, data) { [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') }
png = "\x89PNG\r\n\x1a\n".b + png_chunk.('IHDR', [160, 90, 8, 2, 0, 0, 0].pack('NNC5')) +
png_chunk.('IDAT', Zlib::Deflate.deflate(([0] + [103, 80, 164] * 160).pack('C*') * 90)) + png_chunk.('IEND', '')
[['screenshot.png', png, 'image/png'], ['notes.txt', "Some notes\n", 'text/plain']].each do |name, data, type|
Attachment.create!(file: StringIO.new(data), author: admin, filename: name, content_type: type, container: showcase)
end
# Wiki
wiki = apollo.reload.wiki
start_text = <<~MD
# Apollo wiki
{{toc}}
## Overview
The customer portal lets clients see **tickets**, *invoices* and `usage`.
> Keep this page short. Details go into child pages.
## Team
| Name | Role | Since |
|------|------|-------|
| Jane Doe | Product owner | 2024 |
| Max Smith | Backend | 2025 |
| Linh Nguyen | Frontend | 2026 |
## Setup
```ruby
class Portal
def initialize(user)
@user = user # current user
end
end
```
- [ ] Install dependencies
- [x] Configure database
See [[Architecture]] and issue #13.
MD
page = WikiPage.new(wiki: wiki, title: wiki.start_page)
page.content = WikiContent.new(page: page, text: start_text, author: admin)
page.save!
child = WikiPage.new(wiki: wiki, title: 'Architecture', parent: page)
child.content = WikiContent.new(page: child, text: "# Architecture\n\nThree tiers: web, API, database.", author: people[1])
child.save!
# News, forum, documents
news = News.create!(project: apollo, title: 'Version 2.0 beta available', summary: 'Try the new portal',
description: 'The beta is live on staging. Please report issues.', author: people[0])
Comment.create!(commented: news, author: people[1], content: 'Great work!')
board = Board.create!(project: apollo, name: 'General', description: 'General discussion')
topic = Message.create!(board: board, subject: 'Release planning', content: 'When do we freeze 2.0?', author: people[0])
Message.create!(board: board, parent: topic, subject: 'RE: Release planning', content: 'Next Friday.', author: people[1])
Document.create!(project: apollo, title: 'Requirements', description: 'Signed-off requirements.',
category: DocumentCategory.first)
# Time entries over the last 30 days
activities = TimeEntryActivity.active.to_a
issues.first(20).each_with_index do |issue, i|
user = people[i % 3]
TimeEntry.create!(project: issue.project, issue: issue, user: user, author: user, hours: [0.5, 1, 2, 3.5][i % 4],
spent_on: today - (i % 30), activity: activities[i % activities.size], comments: 'Work on issue')
end
# Saved public query: grouped, with totals
IssueQuery.create!(name: 'Open by assignee', user: admin, visibility: Query::VISIBILITY_PUBLIC,
group_by: 'assigned_to', totalable_names: %w[estimated_hours spent_hours],
column_names: %i[tracker status priority subject assigned_to due_date done_ratio])
end
puts "Seeded #{Project.count} projects, #{Issue.count} issues, #{User.count} users."
load File.join(__dir__, 'spread_activity.rb')
load File.join(__dir__, 'seed_extras.rb')
+67
View File
@@ -0,0 +1,67 @@
# Extra demo data for pages the base seed does not cover: repository, wiki diff, description diff.
# Loaded by seed.rb. Each block has its own guard, so rerunning the seed adds only what is missing.
require 'fileutils'
require 'open3'
require 'tmpdir'
apollo = Project.find_by!(identifier: 'apollo')
admin = User.find_by!(login: 'admin')
User.current = admin
# Git repository: built in a work tree, then cloned bare into the files volume (persists restarts).
unless apollo.repository
bare = '/usr/src/redmine/files/demo-repo.git'
work = Dir.mktmpdir
git = lambda do |*args|
out, status = Open3.capture2e('git', '-C', work, *args)
raise "git #{args.join(' ')} failed: #{out}" unless status.success?
end
env = { 'user.name' => 'Jane Doe', 'user.email' => 'jdoe@example.com' }.flat_map { |k, v| ['-c', "#{k}=#{v}"] }
git.('init', '-q', '-b', 'main')
File.write(File.join(work, 'README.md'), "# Apollo\n\nCustomer portal.\n")
FileUtils.mkdir_p(File.join(work, 'lib'))
File.write(File.join(work, 'lib/portal.rb'), "class Portal\n def initialize(user)\n @user = user\n end\nend\n")
git.(*env, 'add', '.')
git.(*env, 'commit', '-q', '-m', 'Initial import')
File.write(File.join(work, 'lib/portal.rb'),
"class Portal\n def initialize(user, locale = :en)\n @user = user\n @locale = locale\n end\n\n def greeting\n \"Hello, \#{@user}\"\n end\nend\n")
git.(*env, 'commit', '-q', '-am', 'Add locale and greeting (refs #13)')
File.write(File.join(work, 'README.md'), "# Apollo\n\nCustomer portal.\n\n## Setup\n\nRun `bin/setup`.\n")
git.(*env, 'commit', '-q', '-am', 'Document setup')
FileUtils.rm_rf(bare)
out, status = Open3.capture2e('git', 'clone', '-q', '--bare', work, bare)
raise "git clone failed: #{out}" unless status.success?
FileUtils.rm_rf(work)
FileUtils.chown_R('redmine', 'redmine', bare) if Process.uid.zero? # the app runs as redmine
apollo.enabled_module_names = apollo.enabled_module_names | ['repository']
repo = Repository::Git.create!(project: apollo, url: bare, identifier: 'portal', is_default: true)
repo.fetch_changesets
end
# Second wiki version, so history and diff have something to compare
start = apollo.wiki.find_page(apollo.wiki.start_page)
if start && start.content.version == 1
start.content.text = start.content.text.sub('Keep this page short.', 'Keep this page short and current.') +
"\n## Release\n\nVersion 2.0 ships at the end of the sprint.\n"
start.content.author = User.find_by!(login: 'jdoe')
start.content.comments = 'Add release section'
start.content.save!
end
# Description change on the showcase issue, so the journal has a "diff" link
showcase = Issue.find(13) # the issue the screenshot checklist shows
unless showcase.journals.joins(:details).exists?(journal_details: { prop_key: 'description' })
showcase.init_journal(User.find_by!(login: 'msmith'))
showcase.description = "#{showcase.description}\n\nAlso happens in the API search endpoint."
showcase.save!
end
# Second forum, so the forum index lists boards instead of redirecting to the only one
Board.create!(project: apollo, name: 'Support', description: 'Questions from customers') if apollo.boards.count < 2
puts "Extras: #{Repository.count} repositories, wiki v#{start&.content&.reload&.version}, " \
"#{JournalDetail.where(prop_key: 'description').count} description diffs, #{apollo.boards.count} forums."
+60
View File
@@ -0,0 +1,60 @@
# Spreads demo record timestamps over the last two weeks (working hours), so the activity page,
# "updated" columns and history show different days and times. Deterministic (based on ids),
# safe to run again. Called by seed.rb; can also run alone:
# docker compose exec redmine bin/rails runner /seed/spread_activity.rb
now = Time.current
today = Date.current
DAYS = 14
# Day `days_ago` at a working-hour time derived from `n`
at = lambda do |days_ago, n|
(today - days_ago).in_time_zone.change(hour: 8 + (n * 7) % 10, min: (n * 13) % 60)
end
not_future = ->(t, n) { t < now ? t : now - (n % 90 + 5).minutes }
ActiveRecord::Base.transaction do
Issue.order(:id).each_with_index do |issue, i|
created = at.(DAYS - 1 - (i % DAYS), i)
last = created
issue.journals.order(:id).each do |journal|
t = not_future.(created + (1 + journal.id % 3).days + (journal.id * 17 % 300).minutes, journal.id)
journal.update_columns(created_on: t, updated_on: t)
last = [last, t].max
end
closed = issue.closed? ? [last, created + 1.day].max : nil
issue.update_columns(created_on: created, updated_on: last, closed_on: closed && not_future.(closed, issue.id))
end
TimeEntry.find_each do |entry|
t = not_future.(entry.spent_on.in_time_zone.change(hour: 17, min: entry.id * 7 % 60), entry.id)
entry.update_columns(created_on: t, updated_on: t)
end
Attachment.find_each { |a| a.update_columns(created_on: not_future.(a.container.try(:created_on).to_time + 2.hours, a.id)) if a.container }
WikiContentVersion.order(:id).each_with_index do |version, i|
t = at.(9 - i * 2, version.id)
version.update_columns(updated_on: t)
version.page.content.update_columns(updated_on: t) if version.version == version.page.content.version
end
WikiPage.find_each { |page| page.update_columns(created_on: page.content.versions.minimum(:updated_on) || page.created_on) }
News.find_each do |news|
news.update_columns(created_on: at.(6, news.id))
news.comments.each_with_index { |c, i| c.update_columns(created_on: at.(5 - i, c.id), updated_on: at.(5 - i, c.id)) }
end
Message.where(parent_id: nil).find_each do |topic|
topic.update_columns(created_on: at.(4, topic.id), updated_on: at.(4, topic.id))
topic.children.each_with_index do |reply, i|
t = not_future.(at.(3 - i, reply.id), reply.id)
reply.update_columns(created_on: t, updated_on: t)
topic.update_columns(updated_on: t)
end
end
Document.find_each { |d| d.update_columns(created_on: at.(8, d.id)) }
end
puts "Spread activity over the last #{DAYS} days."