rosetta-rails 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +165 -9
  3. data/app/assets/builds/rosetta/application.css +1 -1
  4. data/app/controllers/concerns/rosetta/locale_scoped.rb +1 -2
  5. data/app/controllers/rosetta/default_locales_controller.rb +29 -0
  6. data/app/controllers/rosetta/locales/translations/missing_controller.rb +1 -1
  7. data/app/controllers/rosetta/locales/translations_controller.rb +2 -2
  8. data/app/controllers/rosetta/locales_controller.rb +7 -1
  9. data/app/controllers/rosetta/translations_controller.rb +7 -16
  10. data/app/helpers/rosetta/translation_helper.rb +1 -1
  11. data/app/jobs/rosetta/autodiscovery_job.rb +11 -0
  12. data/app/jobs/rosetta/purge_job.rb +11 -0
  13. data/app/models/rosetta/locale.rb +18 -10
  14. data/app/models/rosetta/text_entry.rb +22 -0
  15. data/app/models/rosetta/translation.rb +11 -2
  16. data/app/views/rosetta/default_locales/new.html.erb +54 -0
  17. data/app/views/rosetta/locales/_locale.html.erb +2 -2
  18. data/app/views/rosetta/locales/translations/_navigation.html.erb +1 -1
  19. data/app/views/rosetta/locales/translations/index.html.erb +4 -3
  20. data/app/views/rosetta/{locales/translations/_translation_key.html.erb → text_entries/_text_entry_with_translation.html.erb} +4 -4
  21. data/app/views/rosetta/translations/edit.html.erb +4 -4
  22. data/config/routes.rb +3 -1
  23. data/db/migrate/20240923100651_add_default_to_rosetta_locales.rb +5 -0
  24. data/db/migrate/20240930135507_create_rosetta_text_entries.rb +14 -0
  25. data/db/migrate/20240930135810_update_rosetta_translations.rb +20 -0
  26. data/db/migrate/20241002152043_drop_translation_keys.rb +5 -0
  27. data/lib/rosetta/configuration.rb +2 -8
  28. data/lib/rosetta/store.rb +11 -29
  29. data/lib/rosetta/translated/create.rb +37 -0
  30. data/lib/rosetta/translated/delete.rb +15 -0
  31. data/lib/rosetta/translated.rb +55 -0
  32. data/lib/rosetta/version.rb +1 -1
  33. data/lib/rosetta-rails.rb +10 -2
  34. metadata +15 -4
  35. data/app/models/rosetta/translation_key.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee1f9560ed3512886854e08403132cb5398b0a3326ce92a70d48a0a778299826
4
- data.tar.gz: f3166d32190e5010226cf6594cc36539b05b37dae175d2211179aa0e4d491d03
3
+ metadata.gz: 4ccad1f37d3dfef704d60338c2d6e99c0d319c02c1a498c94d6fee302bfb6b95
4
+ data.tar.gz: 6c40c3391ebcf6cafeba8b1865dc128b416fa878411be608117764c62dcdc3d0
5
5
  SHA512:
6
- metadata.gz: a6ddba14ed15d543130ab6021e62c23c4e770dc55c73d1664d6b17b36dbc4af882dbf55f887e627bba759423c151244f13e8e498190d828156513c7c27042ae2
7
- data.tar.gz: 47532f27b995939fbdc10338ab01bc95224d3e4e81cac0d04fd6bd13918a92097891c62f08410a73c82c1b019678122dab83d89284367c5afe72f11645680f25
6
+ metadata.gz: 3069a77bea26e6a3707d897f1c358d4b74ffbb1331448d79ab132b43541b06e6d28d7f71ccbf034ec12a035ee73d68b34928da4eb0df1331a80e22f8145ec277
7
+ data.tar.gz: 743d323252d60de6b40980a34b6c107628ad92e0c3bed1638b65b5ac5e7ec45ac5e5556d706dbebede4ec540ae79e6fca203099e7a1128e42d7947a406156d17
data/README.md CHANGED
@@ -1,28 +1,184 @@
1
+ [![CI](https://github.com/virolea/rosetta/actions/workflows/ci.yml/badge.svg)](https://github.com/virolea/rosetta/actions/workflows/ci.yml)
2
+
1
3
  # Rosetta
2
- Short description and motivation.
3
4
 
4
- ## Usage
5
- How to use my plugin.
5
+ Rosetta is a Rails engine proviving a full-fledged internationalization (i18n) solution for your Rails application. It is designed with the following principles in mind:
6
+ - **Unobtrusiveness**: i18n matters should not get in your way developing application code.
7
+ - **Maintanbility**: time should not be spent editing, organizing or deduplicating translation files.
8
+ - **Separation of concerns**: development work is separated from translation work.
9
+
10
+ Rosetta answers those design principles by offering the following features:
11
+ - **Texts as translation keys**: Inspired by the [gettext](https://www.gnu.org/software/gettext/) approach, Rosetta uses the texts themselves as translation keys. The language the codebase is written into is the default locale.
12
+ - **Key autodiscovery**: New translation keys are automatically discovered and uniquely saved to the database. No more time spent between views and yaml files.
13
+ - **A dedicated interface**: Translators can work on a dedicated interface, separated from the codebase. They can search, filter, edit and create translations in a user-friendly environment
14
+
15
+
16
+ A real-life example speaking louder than words, here is a comparison between the Rails default I18n approach and Rosetta:
17
+
18
+ ```erb
19
+ <!-- Rails with I18n -->
20
+ <h1 class="text-2xl font-semibold"><%= t("pages.home.greetings") %> </h1>
21
+ <p class="text-xl font-medium"><%= t("pages.home.introductory_text") %></p>
22
+
23
+ <!-- Rails with Rosetta -->
24
+ <h1 class="text-2xl font-semibold"><%= _ "Hi! Welcome to Rosetta" %> </h1>
25
+ <p class="text-xl font-medium">
26
+ <%= _ "Rosetta is a Rails engine proviving a full-fledged internationalization (i18n) solution for your Rails application." %>
27
+ </p>
28
+ ```
29
+
30
+ ## Roadmap
31
+
32
+ > [!WARNING]
33
+ > Rosetta is still early in its development, and is subject to breaking changes even on minor version updates. Please refer to the [releases section](https://github.com/virolea/rosetta/releases) for more information.
34
+
35
+ |Feature|tracking|released|
36
+ |--|--|--
37
+ |Base translations||0.1.1|
38
+ |Pluralization API|https://github.com/virolea/rosetta/issues/5||
39
+ |Context API|https://github.com/virolea/rosetta/issues/6||
40
+ |Interpolation API|https://github.com/virolea/rosetta/issues/8||
6
41
 
7
42
  ## Installation
8
43
  Add this line to your application's Gemfile:
9
44
 
10
45
  ```ruby
11
- gem "rosetta"
46
+ gem "rosetta-rails"
12
47
  ```
13
48
 
14
49
  And then execute:
15
50
  ```bash
16
- $ bundle
51
+ $ bundle install
17
52
  ```
18
53
 
19
- Or install it yourself as:
54
+ Run the following command to install the required migrations:
20
55
  ```bash
21
- $ gem install rosetta
56
+ $ rails rosetta:install:migrations
57
+ $ rails db:migrate
58
+ ```
59
+
60
+ Finally, mount the engine in your `config/routes.rb` file:
61
+
62
+ ```ruby
63
+ Rails.application.routes.draw do
64
+ mount Rosetta::Engine => "/rosetta"
65
+ end
66
+ ```
67
+
68
+ And that's it! Run your server and visit `http://localhost:3000/rosetta` to check everything is running properly.
69
+
70
+ ## Usage
71
+
72
+ ### The Default locale
73
+
74
+ > [!NOTE]
75
+ > By convention in Rosetta, the default locale should be the locale your codebase is written into. This, in theory should not change once it is set.
76
+
77
+ The first time you visit the Rosetta interface, you will be prompted to set the default locale:
78
+
79
+ ![CleanShot 2024-09-23 at 15 36 40](https://github.com/user-attachments/assets/6d088837-89ac-4742-b5b7-00a7db8f9c2b)
80
+
81
+ Enter the name and the code for the locale your codebase is written into (probably English and `en` in most cases) and submit the form. You will be redirected to the rosetta homepage where your default locale will appear.
82
+
83
+ ![CleanShot 2024-09-19 at 18 01 55](https://github.com/user-attachments/assets/678fad1d-64cd-463d-8c21-9a166abca715)
84
+
85
+ ### Adding a locale
86
+
87
+ Developers shout not worry about managing locales. Locales can be added through the Rosetta interface. Visit the interface and click on the "Add locale" button. You will be prompted to enter the name and code of the locale. Once added, the locale will immediately be available and ready to be translated.
88
+
89
+ ![CleanShot 2024-09-19 at 15 11 21](https://github.com/user-attachments/assets/ffe55e81-5cee-4b5b-9be1-a38abe124dfe)
90
+
91
+ ### Selecting the locale in the application
92
+
93
+ The current locale is globally available in the current thread the application is running in. You can access it through the `Rosetta.locale` method. This will return a `Rosetta::Locale` instance.
94
+
95
+ It is recommended to follow the [Rails guides best practices](https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests) to manage the locale across requests. Rosetta offers a similar helper method to set the locale for the current request:
96
+
97
+ ```ruby
98
+ class ApplicationController < ActionController::Base
99
+ around_action :set_locale
100
+
101
+ private
102
+
103
+ def set_locale(&action)
104
+ Rosetta.with_locale(params[:locale], &action)
105
+ end
22
106
  ```
23
107
 
24
- ## Contributing
25
- Contribution directions go here.
108
+ Where locale is the code of the locale you want to set. You can then set the locale by passing the locale code as a query parameter in the URL, like `http://localhost:3000?locale=fr`. If no locale exists for this code, the default locale is used instead.
109
+
110
+ You can access available locales through `Rosetta.available_locales` to build a selector your users can use to swith between locales:
111
+
112
+ ```erb
113
+ <ul>
114
+ <% Rosetta.available_locales.each do |locale| %>
115
+ <li><%= link_to locale.name, root_path(locale: locale.code) %></li>
116
+ <% end %>
117
+ </ul>
118
+ ```
119
+
120
+ ### Translating your views
121
+
122
+ > [!IMPORTANT]
123
+ > Make sure you've correctly installed rosetta, and that your production environment has run the rosetta migrations properly before invoking the `_` helper in your views.
124
+
125
+ As stated above, Rosetta is built with unobstrusiveness in mind. To translate your view, no need to come up with a translation key, figure out the file and appropriate nesting to write to. All you need to do is include the `Rosetta::TranslationHelper` in your `ApplicationHelper` and wrap your text with the `_` method in your view:
126
+
127
+ ```ruby
128
+ module ApplicationHelper
129
+ include Rosetta::TranslationHelper
130
+ end
131
+ ```
132
+
133
+ ```erb
134
+ <h1"><%= _ "Hi! Welcome to Rosetta" %> </h1>
135
+ ```
136
+
137
+ Rosetta will automatically detect the new translation keys and save them to the database. You can then visit the Rosetta interface to add a translation for a given locale.
138
+
139
+ ### Adding translations
140
+
141
+ Translations can be added through the Rosetta interface. Visit the interface and click on "Manage" button next to the locale you want to add translations for. You will access the list of your app keys, and the corresponding translations, should they exist. To add or edit a translation, hover the translation cell and click on "edit". You can then enter the translation and save it.
142
+
143
+ ![CleanShot 2024-09-19 at 15 14 32](https://github.com/user-attachments/assets/d6d57b0a-034c-409e-8ff1-bfcae84aa1c8)
144
+
145
+
146
+ Saving the new translation **will not immediately reflect in your application**. This is by desgin for performance reasons. To make new translations available in your app, click on the "deploy" button at the top of the page. Translations for the given locale will be reloaded and made available in your app.
147
+
148
+ ![CleanShot 2024-09-19 at 15 15 30](https://github.com/user-attachments/assets/51a9b582-b35e-4df4-a79b-1e0f238715a0)
149
+
150
+
151
+ ### Configuration
152
+
153
+ #### Configuring a base controller
154
+
155
+ By default, Rosetta inherits from `ActionController::Base`. If you want to restrict access to the Rosetta interface, you can make Rosetta inherit from your own base controller, by configuring it in the initializer:
156
+
157
+ ```ruby
158
+ # config/initializers/rosetta.rb
159
+
160
+ Rosetta.configure do |config|
161
+ config.parent_controller_class = "AdminController"
162
+ end
163
+ ```
164
+
165
+ **Note**: The class name needs to be a string.
166
+
167
+ #### Setting up the queue for the Autodiscovery Job
168
+
169
+ Rosetta uses a background job to automatically discover new translations in your codebase. It enqueues jobs in the queue set by default. To configure it to target another queue:
170
+
171
+ ```ruby
172
+ # config/initializers/rosetta.rb
173
+
174
+ Rosetta.configure do |config|
175
+ config.queues[:autodiscovery] = "low_priority"
176
+ end
177
+ ```
178
+
179
+ ### I18n Support
180
+
181
+ As of now, Rosetta does not integrate with the `i18n` gem as a custom backend. It might be done in the future, however it's been decided to build Rosetta independently for now. You still need to use `i18n` for backwards compatibility of your existing translations, localization, as well as the translations of gems that depend on it.
26
182
 
27
183
  ## License
28
184
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1 +1 @@
1
- /*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.btn{border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.btn-primary:focus-visible{outline-color:#4f46e5}.btn-secondary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.label{font-size:.875rem;font-weight:500;line-height:1.5rem}.input,.label{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.input{display:block;width:100%;border-radius:.375rem;border-width:0;padding-top:.375rem;padding-bottom:.375rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.input::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.input::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.input:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}@media (min-width:640px){.input{font-size:.875rem;line-height:1.5rem}}.badge{border-radius:.375rem;--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-inset:inset;--tw-ring-color:hsla(220,9%,46%,.1)}.badge,.pill{display:inline-flex;align-items:center;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.pill{border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-inset:inset;--tw-ring-color:rgba(22,163,74,.2)}.pagy{display:flex;justify-content:center}.pagy>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.pagy{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity));a:not(.gap){display:block;border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity));padding-left:.75rem;padding-right:.75rem;padding-top:.25rem;padding-bottom:.25rem}a:not(.gap){&:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}&:not([href]){cursor:default;--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}&.current{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}}label{display:inline-block;white-space:nowrap;border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity));padding-left:.75rem;padding-right:.75rem;padding-top:.125rem;padding-bottom:.125rem}label{input{border-radius:.375rem;border-style:none;--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.static{position:static}.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.left-0{left:0}.top-0{top:0}.top-4{top:1rem}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.-mb-px{margin-bottom:-1px}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.min-h-full{min-height:100%}.w-1\/2{width:50%}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-28{max-width:7rem}.max-w-4xl{max-width:56rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-2{gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.divide-x>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*(1 - var(--tw-divide-x-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.divide-gray-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(209 213 219/var(--tw-divide-opacity))}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-green-100{--tw-border-opacity:1;border-color:rgb(220 252 231/var(--tw-border-opacity))}.border-indigo-500{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:0.5}.p-12{padding:3rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-px{padding-top:1px;padding-bottom:1px}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.text-left{text-align:left}.text-right{text-align:right}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity))}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.ring-opacity-5{--tw-ring-opacity:0.05}.placeholder\:text-gray-400::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.placeholder\:text-gray-400::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-within\:ring-indigo-600:focus-within{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.hover\:border-gray-200:hover{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.group:hover .group-hover\:flex{display:flex}.group.active .group-\[\.active\]\:bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity))}.group.active .group-\[\.active\]\:text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}@media (min-width:640px){.sm\:ml-16{margin-left:4rem}.sm\:mt-0{margin-top:0}.sm\:flex{display:flex}.sm\:flex-auto{flex:1 1 auto}.sm\:flex-none{flex:none}.sm\:items-center{align-items:center}.sm\:rounded-lg{border-radius:.5rem}.sm\:px-6{padding-right:1.5rem}.sm\:pl-6,.sm\:px-6{padding-left:1.5rem}.sm\:pr-6{padding-right:1.5rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:leading-6{line-height:1.5rem}}@media (min-width:768px){.md\:inline-block{display:inline-block}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}
1
+ /*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.btn{border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.btn-primary:focus-visible{outline-color:#4f46e5}.btn-secondary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.label{font-size:.875rem;font-weight:500;line-height:1.5rem}.input,.label{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.input{display:block;width:100%;border-radius:.375rem;border-width:0;padding-top:.375rem;padding-bottom:.375rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.input::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.input::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.input:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset;--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}@media (min-width:640px){.input{font-size:.875rem;line-height:1.5rem}}.badge{border-radius:.375rem;--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-inset:inset;--tw-ring-color:hsla(220,9%,46%,.1)}.badge,.pill{display:inline-flex;align-items:center;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.pill{border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-inset:inset;--tw-ring-color:rgba(22,163,74,.2)}.pagy{display:flex;justify-content:center}.pagy>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.pagy{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity));a:not(.gap){display:block;border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity));padding-left:.75rem;padding-right:.75rem;padding-top:.25rem;padding-bottom:.25rem}a:not(.gap){&:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}&:not([href]){cursor:default;--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}&.current{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}}label{display:inline-block;white-space:nowrap;border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity));padding-left:.75rem;padding-right:.75rem;padding-top:.125rem;padding-bottom:.125rem}label{input{border-radius:.375rem;border-style:none;--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.static{position:static}.absolute{position:absolute}.relative{position:relative}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.left-0{left:0}.top-0{top:0}.top-4{top:1rem}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.-mb-px{margin-bottom:-1px}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.min-h-full{min-height:100%}.w-1\/2{width:50%}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-28{max-width:7rem}.max-w-4xl{max-width:56rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.resize-none{resize:none}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-2{gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.divide-x>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*(1 - var(--tw-divide-x-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.divide-gray-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(209 213 219/var(--tw-divide-opacity))}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-green-100{--tw-border-opacity:1;border-color:rgb(220 252 231/var(--tw-border-opacity))}.border-indigo-500{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:0.5}.p-12{padding:3rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-px{padding-top:1px;padding-bottom:1px}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.text-left{text-align:left}.text-right{text-align:right}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-black{--tw-ring-opacity:1;--tw-ring-color:rgb(0 0 0/var(--tw-ring-opacity))}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.ring-opacity-5{--tw-ring-opacity:0.05}.placeholder\:text-gray-400::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.placeholder\:text-gray-400::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.focus-within\:ring-2:focus-within{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-within\:ring-indigo-600:focus-within{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.hover\:border-gray-200:hover{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-0:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.group:hover .group-hover\:flex{display:flex}.group.active .group-\[\.active\]\:bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity))}.group.active .group-\[\.active\]\:text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}@media (min-width:640px){.sm\:ml-16{margin-left:4rem}.sm\:mt-0{margin-top:0}.sm\:flex{display:flex}.sm\:flex-auto{flex:1 1 auto}.sm\:flex-none{flex:none}.sm\:items-center{align-items:center}.sm\:rounded-lg{border-radius:.5rem}.sm\:px-6{padding-right:1.5rem}.sm\:pl-6,.sm\:px-6{padding-left:1.5rem}.sm\:pr-6{padding-right:1.5rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:leading-6{line-height:1.5rem}}@media (min-width:768px){.md\:inline-block{display:inline-block}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}
@@ -3,14 +3,13 @@ module Rosetta
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- around_action :set_locale
6
+ before_action :set_locale
7
7
  end
8
8
 
9
9
  private
10
10
 
11
11
  def set_locale(&action)
12
12
  @locale = Locale.find(params[:locale_id])
13
- Rosetta.with_locale(@locale, &action)
14
13
  end
15
14
  end
16
15
  end
@@ -0,0 +1,29 @@
1
+ module Rosetta
2
+ class DefaultLocalesController < ApplicationController
3
+ before_action :ensure_configuration_needed
4
+
5
+ def new
6
+ @locale = Locale.new
7
+ end
8
+
9
+ def create
10
+ @locale = Locale.build_as_default(locale_params)
11
+
12
+ if @locale.save
13
+ redirect_to locales_path
14
+ else
15
+ render :new, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def ensure_configuration_needed
22
+ redirect_to root_path if Locale.default_locale
23
+ end
24
+
25
+ def locale_params
26
+ params.require(:locale).permit(:name, :code)
27
+ end
28
+ end
29
+ end
@@ -3,7 +3,7 @@ module Rosetta
3
3
  private
4
4
 
5
5
  def scope
6
- super.where.missing(:translation_in_current_locale)
6
+ Locale.default_locale.text_entries.missing_translation(@locale)
7
7
  end
8
8
  end
9
9
  end
@@ -3,7 +3,7 @@ module Rosetta
3
3
  include LocaleScoped
4
4
 
5
5
  def index
6
- @pagy, @translation_keys = pagy(scope)
6
+ @pagy, @text_entries = pagy(scope)
7
7
 
8
8
  render "rosetta/locales/translations/index"
9
9
  end
@@ -11,7 +11,7 @@ module Rosetta
11
11
  private
12
12
 
13
13
  def scope
14
- TranslationKey.includes(:translation_in_current_locale)
14
+ TextEntry.with_translated_version(@locale).where(locale: Locale.default_locale)
15
15
  end
16
16
  end
17
17
  end
@@ -1,7 +1,9 @@
1
1
  module Rosetta
2
2
  class LocalesController < ApplicationController
3
+ before_action :ensure_default_locale_exists, only: :index
4
+
3
5
  def index
4
- @locales = [ Locale.default_locale ] + Locale.all
6
+ @locales = Locale.order(default: :desc)
5
7
  end
6
8
 
7
9
  def new
@@ -24,6 +26,10 @@ module Rosetta
24
26
 
25
27
  private
26
28
 
29
+ def ensure_default_locale_exists
30
+ redirect_to new_default_locale_path unless Locale.default_locale
31
+ end
32
+
27
33
  def locale_params
28
34
  params.require(:locale).permit(:name, :code)
29
35
  end
@@ -2,34 +2,25 @@ module Rosetta
2
2
  class TranslationsController < ApplicationController
3
3
  include LocaleScoped
4
4
 
5
- before_action :set_translation_key
6
- before_action :set_translation
5
+ before_action :set_text_entry
7
6
 
8
7
  def edit
9
8
  end
10
9
 
11
10
  def update
12
- if translation_params[:value].blank?
13
- @translation_key.translation_in_current_locale = nil
14
- else
15
- @translation.update(translation_params)
16
- end
11
+ @text_entry.update(text_entry_params)
17
12
 
18
- render partial: "rosetta/locales/translations/translation_key", locals: { translation_key: @translation_key }
13
+ render partial: "rosetta/text_entries/text_entry_with_translation", locals: { text_entry: @text_entry }
19
14
  end
20
15
 
21
16
  private
22
17
 
23
- def set_translation_key
24
- @translation_key = TranslationKey.find(params[:translation_key_id])
18
+ def set_text_entry
19
+ @text_entry = TextEntry.find(params[:text_entry_id])
25
20
  end
26
21
 
27
- def set_translation
28
- @translation = @translation_key.translation_in_current_locale || @translation_key.build_translation_in_current_locale
29
- end
30
-
31
- def translation_params
32
- params.require(:translation).permit(:value)
22
+ def text_entry_params
23
+ params.require(:text_entry).permit(:"content_#{@locale.code}")
33
24
  end
34
25
  end
35
26
  end
@@ -1,7 +1,7 @@
1
1
  module Rosetta
2
2
  module TranslationHelper
3
3
  def _(key, locale: Rosetta.locale)
4
- return key if Rosetta.locale.default_locale?
4
+ return key if Rosetta.locale.default?
5
5
 
6
6
  Rosetta.translate(key, locale: locale) || key
7
7
  end
@@ -0,0 +1,11 @@
1
+ module Rosetta
2
+ class AutodiscoveryJob < Rosetta::ApplicationJob
3
+ queue_as { Rosetta.config.queues[:autodiscovery] }
4
+
5
+ discard_on ActiveRecord::RecordNotUnique
6
+
7
+ def perform(content)
8
+ TextEntry.create!(content: content, locale: Locale.default_locale)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Rosetta
2
+ class PurgeJob < Rosetta::ApplicationJob
3
+ queue_as { Rosetta.config.queues[:purge] }
4
+
5
+ discard_on ActiveRecord::RecordNotFound
6
+
7
+ def perform(text_entry)
8
+ text_entry.purge
9
+ end
10
+ end
11
+ end
@@ -1,31 +1,39 @@
1
1
  module Rosetta
2
2
  class Locale < ApplicationRecord
3
+ class_attribute :registered_classes_for_translations, default: []
4
+
3
5
  CODE_FORMAT = /\A[a-zA-Z]+(-[a-zA-Z]+)?\z/
4
6
 
5
7
  validates :name, :code, presence: true
6
8
  validates :code, uniqueness: true
7
9
  validates :code, format: { with: CODE_FORMAT, message: "must only contain letters separated by an optional dash" }
8
10
 
9
- has_many :translations, dependent: :destroy
11
+ has_many :text_entries, dependent: :destroy
12
+ after_create_commit :notify_translated_models
10
13
 
11
14
  class << self
12
15
  def available_locales
13
- [ Locale.default_locale ] + all
16
+ all
17
+ end
18
+
19
+ def build_as_default(params)
20
+ new(params.merge(default: true))
14
21
  end
15
22
 
16
23
  def default_locale
17
- @default_locale ||= new(Rosetta.config.default_locale.to_h).as_default
24
+ @default_locale ||= find_by(default: true)
18
25
  end
19
- end
20
26
 
21
- def default_locale?
22
- @default
27
+ def default_locale=(locale)
28
+ @default_locale = locale
29
+ end
30
+ def register_class_for_translation(klass)
31
+ registered_classes_for_translations << klass
32
+ end
23
33
  end
24
34
 
25
- def as_default
26
- @default = true
27
- readonly!
28
- self
35
+ def notify_translated_models
36
+ registered_classes_for_translations.each { |klass| klass.translated_in(self) }
29
37
  end
30
38
  end
31
39
  end
@@ -0,0 +1,22 @@
1
+ module Rosetta
2
+ class TextEntry < ApplicationRecord
3
+ include Translated
4
+
5
+ translate_in_all_locales
6
+
7
+ belongs_to :locale
8
+
9
+ def self.create_later(content)
10
+ AutodiscoveryJob.perform_later(content)
11
+ end
12
+
13
+ def purge
14
+ destroy
15
+ rescue ActiveRecord::InvalidForeignKey
16
+ end
17
+
18
+ def purge_later
19
+ PurgeJob.perform_later(self)
20
+ end
21
+ end
22
+ end
@@ -1,6 +1,15 @@
1
1
  module Rosetta
2
2
  class Translation < ApplicationRecord
3
- belongs_to :locale, class_name: "Rosetta::Locale", inverse_of: :translations
4
- belongs_to :translation_key, class_name: "Rosetta::TranslationKey", inverse_of: :translations
3
+ belongs_to :target_locale, class_name: "Rosetta::Locale"
4
+ belongs_to :from, class_name: "Rosetta::TextEntry"
5
+ belongs_to :to, class_name: "Rosetta::TextEntry"
6
+
7
+ after_destroy_commit :purge_orphaned_text_entries_later
8
+
9
+ private
10
+
11
+ def purge_orphaned_text_entries_later
12
+ to.purge_later
13
+ end
5
14
  end
6
15
  end
@@ -0,0 +1,54 @@
1
+ <h1 class="text-3xl font-bold">
2
+ Setup default locale
3
+ </h1>
4
+
5
+ <div class="rounded-md bg-blue-50 p-4 mt-8">
6
+ <div class="flex">
7
+ <div class="flex-shrink-0">
8
+ <svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
9
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
10
+ </svg>
11
+ </div>
12
+ <div class="ml-3">
13
+ <h3 class="font-medium text-blue-800">Rosetta needs a default locale set up to work properly</h3>
14
+
15
+ <p class="text-sm text-blue-700 mt-2">
16
+ The default locale is the locale the codebase is written in. It is not supposed to change in the future, though it can be edited if needed later on.
17
+ </p>
18
+ </div>
19
+ </div>
20
+ </div>
21
+
22
+ <%= form_with model: @locale, url: default_locale_path, class: "grid gap-2 mt-8" do |f| %>
23
+ <% if @locale.errors[:code].any? %>
24
+ <div class="rounded-md bg-red-50 p-4">
25
+ <div class="flex">
26
+ <div class="flex-shrink-0">
27
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
28
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
29
+ </svg>
30
+ </div>
31
+ <div class="ml-3">
32
+ <div class="text-sm text-red-700">
33
+ Please check the format of your locale code, and make sure it does not already exist.
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <% end %>
39
+
40
+ <div>
41
+ <%= f.label :name, class: "label" %>
42
+ <%= f.text_field :name, placeholder: "E.g. English", class: "input max-w-xs", required: true %>
43
+ </div>
44
+
45
+ <div>
46
+ <%= f.label :code, class: "label" %>
47
+ <%= f.text_field :code, placeholder: "E.g. en", class: "input max-w-28", required: true %>
48
+ <p class="mt-1 text-sm leading-6 text-gray-600">
49
+ Downcase letters followed by an optional region specifier in uppercase letters, separated by a dash. E.g. <span class="badge">fr</span> or <span class="badge">en-GB</span>.
50
+ </p>
51
+ </div>
52
+
53
+ <%= f.submit "Setup default locale", class: "btn btn-primary mt-2" %>
54
+ <% end %>
@@ -8,11 +8,11 @@
8
8
  </span>
9
9
  </td>
10
10
  <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
11
- <% if locale.default_locale? %>
11
+ <% if locale.default? %>
12
12
  <span class="pill">Default locale</span>
13
13
  <% end %>
14
14
 
15
- <% unless locale.default_locale? %>
15
+ <% unless locale.default? %>
16
16
  <%= link_to "Manage", locale_translations_path(locale), class: "text-indigo-600 hover:text-indigo-900" %>
17
17
  <% end %>
18
18
  </td>
@@ -6,7 +6,7 @@
6
6
  <%= tab_link_to locale_translations_missing_index_path(@locale) do %>
7
7
  Missing
8
8
  <span class="ml-3 hidden md:inline-block rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-900 group-[.active]:bg-indigo-100 group-[.active]:text-indigo-600">
9
- <%= Rosetta::TranslationKey.where.missing(:translation_in_current_locale).size %>
9
+ <%= Rosetta::TextEntry.where(locale: Rosetta::Locale.default_locale).missing_translation(@locale).size %>
10
10
  </span>
11
11
  <% end %>
12
12
  </nav>
@@ -33,15 +33,16 @@
33
33
  <table class="min-w-full divide-y divide-gray-300">
34
34
  <thead class="bg-gray-50">
35
35
  <tr class="divide-x divide-gray-200">
36
- <th scope="col" class="w-1/2 py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Translation Key</th>
36
+ <th scope="col" class="w-1/2 py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Text Entry</th>
37
37
  <th scope="col" class="w-1/2 px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Translation</th>
38
38
  </tr>
39
39
  </thead>
40
40
 
41
41
  <tbody class="divide-y divide-gray-200 bg-white">
42
42
  <%= render(
43
- collection: @translation_keys,
44
- partial: "rosetta/locales/translations/translation_key") %>
43
+ collection: @text_entries,
44
+ as: :text_entry,
45
+ partial: "rosetta/text_entries/text_entry_with_translation") %>
45
46
  </tbody>
46
47
  </table>
47
48
  </div>
@@ -1,13 +1,13 @@
1
1
  <tr class="divide-x divide-gray-200">
2
2
  <td class="py-4 pl-4 pr-3 text-sm text-gray-900 sm:pl-6">
3
- <%= translation_key.value %>
3
+ <%= text_entry.content %>
4
4
  </td>
5
5
  <td class="px-3 py-4 text-sm text-gray-900 group relative">
6
- <%= turbo_frame_tag dom_id(translation_key) do %>
7
- <%= translation_key.translation_in_current_locale&.value %>
6
+ <%= turbo_frame_tag dom_id(text_entry) do %>
7
+ <%= text_entry.content_in(@locale) %>
8
8
 
9
9
  <div class="hidden group-hover:flex absolute w-full h-full top-0 left-0 bg-indigo-50 bg-opacity-50 pr-4 items-center justify-end">
10
- <%= link_to "edit", edit_translation_key_translation_path(translation_key, locale_id: @locale.id), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
10
+ <%= link_to "edit", edit_text_entry_translation_path(text_entry, locale_id: @locale.id), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
11
11
  </div>
12
12
  <% end %>
13
13
  </td>
@@ -1,10 +1,10 @@
1
- <%= turbo_frame_tag dom_id(@translation_key) do %>
2
- <%= form_with model: @translation, url: translation_key_translation_path(@translation_key), method: :patch, class: "relative" do |f| %>
1
+ <%= turbo_frame_tag dom_id(@text_entry) do %>
2
+ <%= form_with model: @text_entry, url: text_entry_translation_path(@text_entry), method: :patch, class: "relative" do |f| %>
3
3
  <%= hidden_field_tag :locale_id, @locale.id %>
4
4
 
5
5
  <div class="overflow-hidden rounded-lg shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-indigo-600">
6
- <%= f.label :value, "Translation", class: "sr-only" %>
7
- <%= f.text_area :value, row: 3, autofocus: true, placeholder: "Enter your translation", class: "block w-full resize-none border-0 bg-transparent py-1.5 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" %>
6
+ <%= f.label :"content_#{@locale.code}", "Translation", class: "sr-only" %>
7
+ <%= f.text_area :"content_#{@locale.code}", row: 3, autofocus: true, placeholder: "Enter your translation", class: "block w-full resize-none border-0 bg-transparent py-1.5 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" %>
8
8
 
9
9
  <div class="py-2" aria-hidden="true">
10
10
  <div class="py-px">
data/config/routes.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  Rosetta::Engine.routes.draw do
2
2
  root to: "locales#index"
3
3
 
4
+ resource :default_locale, only: %i[new create]
5
+
4
6
  resources :locales do
5
7
  scope module: :locales do
6
8
  resources :translations, only: :index
@@ -12,7 +14,7 @@ Rosetta::Engine.routes.draw do
12
14
  end
13
15
  end
14
16
 
15
- resources :translation_keys do
17
+ resources :text_entries do
16
18
  resource :translation, only: %i[edit update]
17
19
  end
18
20
  end
@@ -0,0 +1,5 @@
1
+ class AddDefaultToRosettaLocales < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :rosetta_locales, :default, :boolean, default: false
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ class CreateRosettaTextEntries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :rosetta_text_entries do |t|
4
+ t.text :content, null: false
5
+ t.references :locale, null: false
6
+
7
+ t.timestamps
8
+
9
+ t.index :content
10
+ t.index [ :locale_id, :content ], unique: true
11
+ t.foreign_key :rosetta_locales, column: :locale_id
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ class UpdateRosettaTranslations < ActiveRecord::Migration[7.2]
2
+ def change
3
+ change_table :rosetta_translations do |t|
4
+ t.remove :value
5
+
6
+ t.references :target_locale, null: false
7
+ t.references :from, null: false
8
+ t.references :to, null: false
9
+
10
+ t.foreign_key :rosetta_locales, column: :target_locale_id
11
+ t.foreign_key :rosetta_text_entries, column: :from_id
12
+ t.foreign_key :rosetta_text_entries, column: :to_id
13
+
14
+ t.index [ :target_locale_id, :from_id, :to_id ], name: :rosetta_translations_uniqueness, unique: true
15
+ end
16
+
17
+ remove_reference :rosetta_translations, :translation_key
18
+ remove_reference :rosetta_translations, :locale
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ class DropTranslationKeys < ActiveRecord::Migration[7.2]
2
+ def change
3
+ drop_table :rosetta_translation_keys
4
+ end
5
+ end
@@ -1,17 +1,11 @@
1
1
  module Rosetta
2
2
  class Configuration
3
- attr_reader :default_locale
4
3
  attr_accessor :parent_controller_class
5
-
6
- DefaultLocale = Struct.new(:name, :code)
4
+ attr_accessor :queues
7
5
 
8
6
  def initialize
9
- set_default_locale(name: "English", code: "en")
10
7
  @parent_controller_class = "ActionController::Base"
11
- end
12
-
13
- def set_default_locale(name:, code:)
14
- @default_locale = DefaultLocale.new(name, code)
8
+ @queues = {}
15
9
  end
16
10
  end
17
11
  end
data/lib/rosetta/store.rb CHANGED
@@ -13,19 +13,16 @@ module Rosetta
13
13
  def initialize(locale)
14
14
  @locale = locale
15
15
  @cache_expiration_timestamp = @locale.updated_at
16
- @autodiscovery_queue = Queue.new
17
-
18
- start_key_autodiscovery!
19
16
  end
20
17
 
21
- def lookup(key_value)
22
- if translations.has_key?(key_value)
23
- translations[key_value]
18
+ def lookup(content)
19
+ if translations.has_key?(content)
20
+ translations[content]
24
21
  else
25
- @autodiscovery_queue << key_value
22
+ TextEntry.create_later(content)
26
23
  # Set the key in the translations store to locate it
27
24
  # once only.
28
- translations[key_value] = nil
25
+ translations[content] = nil
29
26
  end
30
27
  end
31
28
 
@@ -47,29 +44,14 @@ module Rosetta
47
44
  private
48
45
 
49
46
  def load_translations
50
- loaded_translations = Rosetta.with_locale(@locale) do
51
- TranslationKey
52
- .includes(:translation_in_current_locale)
53
- .map do |translation_key|
54
- [ translation_key.value, translation_key.translation_in_current_locale&.value ]
55
- end.to_h
56
- end
47
+ loaded_translations = TextEntry
48
+ .with_translated_version(@locale)
49
+ .where(locale: Rosetta::Locale.default_locale)
50
+ .map do |text_entry|
51
+ [ text_entry.content, text_entry.public_send(:"#{@locale.code}_translated_version")&.content ]
52
+ end.to_h
57
53
 
58
54
  Concurrent::Hash.new.merge(loaded_translations)
59
55
  end
60
-
61
- def start_key_autodiscovery!
62
- Thread.new do
63
- Thread.current.name = "Rosetta #{locale.code} store thread"
64
-
65
- loop do
66
- key_value = @autodiscovery_queue.pop
67
-
68
- unless TranslationKey.exists?(value: key_value)
69
- TranslationKey.create!(value: key_value)
70
- end
71
- end
72
- end
73
- end
74
56
  end
75
57
  end
@@ -0,0 +1,37 @@
1
+ module Rosetta
2
+ class Translated::Create
3
+ attr_reader :content
4
+
5
+ def initialize(record, locale, content)
6
+ @record = record
7
+ @locale = locale
8
+ @content = content
9
+ end
10
+
11
+ def save
12
+ @record.public_send(:"#{@locale.code}_translation=", translation)
13
+ end
14
+
15
+ def translated_version
16
+ @translated_version ||= find_or_build_translated_version
17
+ end
18
+
19
+ def translation
20
+ @record.public_send(:"build_#{@locale.code}_translation", to: translated_version)
21
+ end
22
+
23
+ private
24
+
25
+ def find_or_build_translated_version
26
+ find_translated_version || build_translated_version
27
+ end
28
+
29
+ def find_translated_version
30
+ TextEntry.find_by(locale: @locale, content: @content)
31
+ end
32
+
33
+ def build_translated_version
34
+ TextEntry.new(locale: @locale, content: @content)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ module Rosetta
2
+ class Translated::Delete
3
+ attr_reader :content
4
+
5
+ def initialize(record, locale)
6
+ @record = record
7
+ @locale = locale
8
+ @content = nil
9
+ end
10
+
11
+ def save
12
+ @record.public_send(:"#{@locale.code}_translation=", nil)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ module Rosetta
2
+ module Translated
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ scope :missing_translation, ->(locale) { where.missing(:"#{locale.code}_translation") }
7
+ scope :with_translated_version, ->(locale) { includes(:"#{locale.code}_translated_version") }
8
+ end
9
+
10
+ class_methods do
11
+ def translate_in_all_locales
12
+ Locale.all.each do |locale|
13
+ translated_in(locale)
14
+ end
15
+
16
+ Locale.register_class_for_translation(self)
17
+ end
18
+
19
+ def translated_in(locale)
20
+ has_one :"#{locale.code}_translation", -> { where(target_locale: locale) }, class_name: "Rosetta::Translation", foreign_key: :from_id, dependent: :destroy
21
+ has_one :"#{locale.code}_translated_version", through: :"#{locale.code}_translation", source: :to
22
+
23
+ define_method("content_#{locale.code}") do
24
+ if translation_changes[locale.code]
25
+ translation_changes[locale.code].content
26
+ else
27
+ public_send(:"#{locale.code}_translated_version")&.content
28
+ end
29
+ end
30
+
31
+ define_method("content_#{locale.code}=") do |localized_content|
32
+ translation_changes[locale.code] = if localized_content.blank?
33
+ Rosetta::Translated::Delete.new(self, locale)
34
+ else
35
+ Rosetta::Translated::Create.new(self, locale, localized_content)
36
+ end
37
+ end
38
+
39
+ after_save { translation_changes[locale.code]&.save }
40
+ end
41
+ end
42
+
43
+ def content_in(locale)
44
+ public_send("content_#{locale.code}")
45
+ end
46
+
47
+ def translation_changes
48
+ @translation_changes ||= {}
49
+ end
50
+
51
+ def reload(*)
52
+ super.tap { @translation_changes = nil }
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,3 @@
1
1
  module Rosetta
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/rosetta-rails.rb CHANGED
@@ -5,6 +5,10 @@ require "rosetta/locale_session"
5
5
  require "rosetta/store"
6
6
  require "rosetta/configuration"
7
7
 
8
+ require "rosetta/translated"
9
+ require "rosetta/translated/create"
10
+ require "rosetta/translated/delete"
11
+
8
12
  module Rosetta
9
13
  module Base
10
14
  def locale
@@ -30,9 +34,13 @@ module Rosetta
30
34
  Thread.current[:rosetta_locale_session] ||= LocaleSession.new
31
35
  end
32
36
 
33
- def translate(key, locale: Rosetta.locale)
37
+ def translate(content, locale: Rosetta.locale)
34
38
  store = Store.for_locale(locale)
35
- store.lookup(key)
39
+ store.lookup(content)
40
+ end
41
+
42
+ def available_locales
43
+ Locale.available_locales
36
44
  end
37
45
 
38
46
  def config
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rosetta-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vincent Rolea
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-19 00:00:00.000000000 Z
11
+ date: 2024-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -100,6 +100,7 @@ files:
100
100
  - app/assets/stylesheets/rosetta/application.css
101
101
  - app/controllers/concerns/rosetta/locale_scoped.rb
102
102
  - app/controllers/rosetta/application_controller.rb
103
+ - app/controllers/rosetta/default_locales_controller.rb
103
104
  - app/controllers/rosetta/locales/deploys_controller.rb
104
105
  - app/controllers/rosetta/locales/translations/missing_controller.rb
105
106
  - app/controllers/rosetta/locales/translations_controller.rb
@@ -110,31 +111,41 @@ files:
110
111
  - app/helpers/rosetta/navigation_helper.rb
111
112
  - app/helpers/rosetta/translation_helper.rb
112
113
  - app/jobs/rosetta/application_job.rb
114
+ - app/jobs/rosetta/autodiscovery_job.rb
115
+ - app/jobs/rosetta/purge_job.rb
113
116
  - app/mailers/rosetta/application_mailer.rb
114
117
  - app/models/rosetta/application_record.rb
115
118
  - app/models/rosetta/locale.rb
119
+ - app/models/rosetta/text_entry.rb
116
120
  - app/models/rosetta/translation.rb
117
- - app/models/rosetta/translation_key.rb
118
121
  - app/views/layouts/rosetta/_dialog.html.erb
119
122
  - app/views/layouts/rosetta/_flashes.html.erb
120
123
  - app/views/layouts/rosetta/_navbar.html.erb
121
124
  - app/views/layouts/rosetta/application.html.erb
125
+ - app/views/rosetta/default_locales/new.html.erb
122
126
  - app/views/rosetta/locales/_form.html.erb
123
127
  - app/views/rosetta/locales/_locale.html.erb
124
128
  - app/views/rosetta/locales/index.html.erb
125
129
  - app/views/rosetta/locales/new.html.erb
126
130
  - app/views/rosetta/locales/translations/_navigation.html.erb
127
- - app/views/rosetta/locales/translations/_translation_key.html.erb
128
131
  - app/views/rosetta/locales/translations/index.html.erb
132
+ - app/views/rosetta/text_entries/_text_entry_with_translation.html.erb
129
133
  - app/views/rosetta/translations/edit.html.erb
130
134
  - config/initializers/pagy.rb
131
135
  - config/routes.rb
132
136
  - db/migrate/20240830123523_create_rosetta_tables.rb
137
+ - db/migrate/20240923100651_add_default_to_rosetta_locales.rb
138
+ - db/migrate/20240930135507_create_rosetta_text_entries.rb
139
+ - db/migrate/20240930135810_update_rosetta_translations.rb
140
+ - db/migrate/20241002152043_drop_translation_keys.rb
133
141
  - lib/rosetta-rails.rb
134
142
  - lib/rosetta/configuration.rb
135
143
  - lib/rosetta/engine.rb
136
144
  - lib/rosetta/locale_session.rb
137
145
  - lib/rosetta/store.rb
146
+ - lib/rosetta/translated.rb
147
+ - lib/rosetta/translated/create.rb
148
+ - lib/rosetta/translated/delete.rb
138
149
  - lib/rosetta/version.rb
139
150
  - lib/tasks/rosetta_tasks.rake
140
151
  homepage: https://github.com/virolea/rosetta
@@ -1,7 +0,0 @@
1
- module Rosetta
2
- class TranslationKey < ApplicationRecord
3
- has_many :translations, dependent: :destroy
4
- # Note: Learn about the design decisions behind this: https://github.com/virolea/rosetta/issues/3
5
- has_one :translation_in_current_locale, -> { where(locale_id: Rosetta.locale.id) }, class_name: "Translation", dependent: :destroy
6
- end
7
- end