Compare commits

..

92 Commits

Author SHA1 Message Date
Ondřej Hruška cf3120eb68
show table authors in the favourites listing 6 years ago
Ondřej Hruška e6aea514df
allow faving own tables in the gui 6 years ago
Ondřej Hruška c29b6d4bac
fix double faves 6 years ago
Ondřej Hruška 53c81f1c4b
added Most Liked table search option 6 years ago
Ondřej Hruška 2e400b9682
Merge branch 'faves' 6 years ago
Ondřej Hruška 3f69ef3390
added the favourites feature 6 years ago
Ondřej Hruška 86b7db48b3
add comments 6 years ago
Ondřej Hruška 6c2d19b55c
Fix problem with columns named 'id', 'data' etc 6 years ago
Ondřej Hruška 7969e9bbf4
added some doc comments 6 years ago
Ondřej Hruška 796b2f23d6
fix up sitemap priorities 6 years ago
Ondřej Hruška 41d4eac09f
add xml sitemap 6 years ago
Ondřej Hruška 1d38f51a3a
fix large gap above bio table if bio is not filled 6 years ago
Ondřej Hruška 6b04218f67
Add sort dropdown to profile pages 6 years ago
Ondřej Hruška 4392ab9d7b
Add titles to all pages 6 years ago
Ondřej Hruška d5f516853d
properly handle wrong and default sort arg in dash table list 6 years ago
Ondřej Hruška f594ef412e
make (current) in revisions list clickable 6 years ago
Ondřej Hruška 57a8fc58d6
add Longest and Shortest filters 6 years ago
Ondřej Hruška 3629c977e5
rename most updated to most changed 6 years ago
Ondřej Hruška a9426759d2
add different table sort options, change default to last-updated 6 years ago
Ondřej Hruška 83392e07ac
use diffForHumans for revisions page too 6 years ago
Ondřej Hruška 8d1de066e7
fix error if not logged in 6 years ago
Ondřej Hruška cff01551de
add revision reverting option 6 years ago
Ondřej Hruška 34e432fe87
added a revisions page 6 years ago
Ondřej Hruška a69c3e6cf6 Merge branch 'master' of cpsdqs/datatable.directory into master 6 years ago
Ondřej Hruška 7f48d14ce3
Fix table names are globally unique instead of per user 6 years ago
Ondřej Hruška c76fc21820
Brutal insert speed optimization by bypassing the ORM completely 6 years ago
Ondřej Hruška 13b22f7cb4
add option to upload file when creating a table 6 years ago
Ondřej Hruška 7ae41b368e
fix injection bugs in table editor 6 years ago
Ondřej Hruška b716260a53
Fix very slow table display when filled with ugly data 6 years ago
cpsdqs 693d3b9c93
Make table infobox work better at ~900px, fix menu button alignment on small screens 6 years ago
Ondřej Hruška 577a5349c4
reduce nbr of users in the dash column 6 years ago
Ondřej Hruška b0f77be93d
added a donate and faq page 6 years ago
Ondřej Hruška 94fc84615c
add beta symbol 6 years ago
Ondřej Hruška e1c7114bd4
correctly sort by numeric columns 6 years ago
Ondřej Hruška 8d6ec7b47d
up and down arrows now work as vertical tab in row editor 6 years ago
Ondřej Hruška d3a7381f39
Edit All button in row editor 6 years ago
Ondřej Hruška 0d938ec8af
improve behavior of draft discard button, plus clearer text 6 years ago
Ondřej Hruška 074cda7fe4
trim all fields when importing via CSV. spaces are rarely intentional 6 years ago
Ondřej Hruška 4961430c8f
also order exports 6 years ago
Ondřej Hruška 624963b729
order by first column 6 years ago
Ondřej Hruška f42e80cc5d
fix stupid json unpacking bug turning indexed array to object 6 years ago
Ondřej Hruška f6109b5d4a
fix deleting columns 6 years ago
Ondřej Hruška c0bc64a2e7
fix camel columns 6 years ago
Ondřej Hruška 93f28e38ed
fix confirm cmd oops 6 years ago
Ondřej Hruška 00f238d625
fix login form ugly wrapping of auth buttons 6 years ago
Ondřej Hruška ceca9af0fc
add un-confirm option to user:confirm, hide New button from dashboard if user isn't confirmed 6 years ago
Ondřej Hruška aa677efd51
remove throttle from web 6 years ago
Ondřej Hruška 9b722aa7c9
Fix cannot add new cols in new table 6 years ago
Ondřej Hruška 9ec5a151cc
Fixes in value revert logic 6 years ago
Ondřej Hruška 2cadbdcd31
fix CSV import asking for cols that were removed 6 years ago
Ondřej Hruška bc960e80aa
fix proposals table unable to rollback 6 years ago
Ondřej Hruška 20f4694ec4
make proposal table_id nullable 6 years ago
Ondřej Hruška f2910f977f
Working proposal apply code, fixed schema (need reset - artisan migrate:fresh) 6 years ago
Ondřej Hruška 8efc31d820
Use temporary negative IDs for rows created in a draft Changeset 6 years ago
Ondřej Hruška 7938519a64
doc comments 6 years ago
Ondřej Hruška adc53cee95
Submit route, fixes in col editor 6 years ago
Ondřej Hruška 69dabab6cc
improve interactivity in Review page 6 years ago
Ondřej Hruška 70cff26719
fix col order reset button, show only if order is changed 6 years ago
Ondřej Hruška 58c28b776b
Note saving, revert buttons in review page 6 years ago
Ondřej Hruška b1d1fa4880
Note tab added 6 years ago
Ondřej Hruška c952798e66
comments and cleaning in TableEditController 6 years ago
Ondřej Hruška 23f27e6f34
use control-group for add-rows widget, fix paginator overflowing with hundreds of items 6 years ago
Ondřej Hruška a3df7a724c
CSV import, also from file, and more UX improvements 6 years ago
Ondřej Hruška 1e183f5059
UX improvements in table editor 6 years ago
Ondřej Hruška c642774316
Hide unimplemented feature buttons and indicators with feature flags 6 years ago
Ondřej Hruška aceb0453fc
Column order saving to Changeset 6 years ago
Ondřej Hruška 1fbd3384ce
implement column edit api, except sort 6 years ago
Ondřej Hruška e3a8616c68
better table spacing, bugfix in axios query code 6 years ago
Ondřej Hruška 81261aba8b
improve styles and behavior of the col editor, preparing for backend impl 6 years ago
Ondřej Hruška ef6e89bab6
a hack to keep focus on the sort button 6 years ago
Ondřej Hruška e7a1e38e18
Fix sorting code for steps > 1 6 years ago
Ondřej Hruška 786c18cec9
doc comments 6 years ago
Ondřej Hruška 4570239548
hide manual sort buttons 6 years ago
Ondřej Hruška 9ca3b93847
Preparing column editor for backend requests, remove simple col editor and reuse one for both cases 6 years ago
Ondřej Hruška 7aca9ded60 Merge branch 'master' of cpsdqs/dtbl-laravel into master 6 years ago
cpsdqs b8dbafd270
Add column editor animations and drag & drop 6 years ago
Ondřej Hruška bda9c06488
use btn-group for up/down buttons 6 years ago
Ondřej Hruška 1e0581d9ab
added fa config files, stub of Column editor 6 years ago
Ondřej Hruška 7121cfaabd
add eslint and stuff 6 years ago
Ondřej Hruška 3f74e15115
change divs to spans to be more Valid™ 6 years ago
Ondřej Hruška bfdf1ece5a
slightly improve menus 6 years ago
Ondřej Hruška 1e8deaef1a
fix ridiculous dark mode button look with noscript 6 years ago
Ondřej Hruška f48887b21a
more !important 6 years ago
Ondřej Hruška fa05d8c9d0
whoopsie forgot the darkmode js file 6 years ago
Ondřej Hruška 84103d6001
Merge branch 'editing', split darkmode to own file 6 years ago
Ondřej Hruška 8cbfa93adf
simplify css for table infobox 6 years ago
Ondřej Hruška d870da7e05
Simplify dark scss, some fixes, more convenient dark_mode() 6 years ago
Ondřej Hruška 3d03727811
Add missing icons 6 years ago
Ondřej Hruška 89ca44fa14 Merge branch 'dark-mode' of cpsdqs/dtbl-laravel into master 6 years ago
cpsdqs 09a4b50697
Use jQuery and use data-* instead of plain attributes 6 years ago
cpsdqs a63e605a15
Use cookies for dark mode 6 years ago
cpsdqs 9fd6890667
Add dark mode [db] 6 years ago
  1. 11
      .eslintrc
  2. 4
      Makefile
  3. 1
      _json_typehints.php
  4. 12
      app/Console/Commands/ConfirmUser.php
  5. 21
      app/Http/Controllers/DashController.php
  6. 49
      app/Http/Controllers/ProfileController.php
  7. 139
      app/Http/Controllers/SitemapController.php
  8. 216
      app/Http/Controllers/TableController.php
  9. 258
      app/Http/Controllers/TableEditController.php
  10. 1
      app/Http/Kernel.php
  11. 131
      app/Models/Proposal.php
  12. 11
      app/Models/Revision.php
  13. 68
      app/Models/Row.php
  14. 99
      app/Models/Table.php
  15. 3
      app/Models/User.php
  16. 15
      app/Tables/BaseExporter.php
  17. 14
      app/Tables/BaseNumerator.php
  18. 525
      app/Tables/Changeset.php
  19. 51
      app/Tables/Column.php
  20. 22
      app/Tables/DraftRowNumerator.php
  21. 3
      app/Tables/RowNumerator.php
  22. 18
      app/View/WidgetFactory.php
  23. 18
      app/helpers.php
  24. 13
      config/app.php
  25. 1
      database/migrations/2018_07_08_193600_create_revisions_table.php
  26. 13
      database/migrations/2018_07_08_194000_create_proposals_table.php
  27. 37
      database/migrations/2018_07_08_194105_create_revision_proposal_pivot_table.php
  28. 846
      package-lock.json
  29. 6
      package.json
  30. 17
      porklib/Providers/BladeExtensionsProvider.php
  31. 19
      porklib/Utils/Utils.php
  32. 2
      porklib/helpers.php
  33. 321
      public/fonts/fa-dtbl-1-preview.html
  34. 78
      public/fonts/fa-dtbl-1.css
  35. BIN
      public/fonts/fa-dtbl-1.eot
  36. 117
      public/fonts/fa-dtbl-1.svg
  37. BIN
      public/fonts/fa-dtbl-1.ttf
  38. BIN
      public/fonts/fa-dtbl-1.woff2
  39. 2
      public/images/logo-dark.svg
  40. 3
      public/mix-manifest.json
  41. 18
      resources/assets/fa-config/fontcustom.yml
  42. 96
      resources/assets/fa-config/wanted.ini
  43. 7
      resources/assets/js/app.js
  44. 2
      resources/assets/js/base-setup.js
  45. 487
      resources/assets/js/components/ColumnEditor.vue
  46. 38
      resources/assets/js/components/DraftNotePage.js
  47. 387
      resources/assets/js/components/RowsEditor.vue
  48. 5
      resources/assets/js/components/_base.scss
  49. 26
      resources/assets/js/components/table-editor-utils.js
  50. 7
      resources/assets/js/lib/url-slug.js
  51. 4
      resources/assets/js/modules/block-collapse.js
  52. 35
      resources/assets/js/modules/dark-mode.js
  53. 2
      resources/assets/js/modules/flash-messages.js
  54. 6
      resources/assets/js/udash.js
  55. 36
      resources/assets/js/vue-init.js
  56. 9
      resources/assets/sass/_block-collapse.scss
  57. 2
      resources/assets/sass/_bootstrap.scss
  58. 13
      resources/assets/sass/_fa-utils.scss
  59. 17
      resources/assets/sass/_funding.scss
  60. 21
      resources/assets/sass/_helpers.scss
  61. 12
      resources/assets/sass/_infobox.scss
  62. 4
      resources/assets/sass/app-dark.scss
  63. 18
      resources/assets/sass/app.scss
  64. 4
      resources/assets/sass/bootstrap-customizations/_card.scss
  65. 2
      resources/assets/sass/bootstrap-customizations/_footer.scss
  66. 7
      resources/assets/sass/bootstrap-customizations/_nav.scss
  67. 22
      resources/assets/sass/bootstrap-customizations/_navbar.scss
  68. 3
      resources/assets/sass/bootstrap-customizations/_notification.scss
  69. 28
      resources/assets/sass/bootstrap-customizations/_responsive.scss
  70. 4
      resources/assets/sass/bootstrap-customizations/_table.scss
  71. 57
      resources/assets/sass/bootstrap-customizations/_variables.scss
  72. 54
      resources/views/about/donate.blade.php
  73. 99
      resources/views/about/faq.blade.php
  74. 17
      resources/views/about/privacy.blade.php
  75. 41
      resources/views/about/terms.blade.php
  76. 5
      resources/views/auth/login.blade.php
  77. 1
      resources/views/auth/passwords/email.blade.php
  78. 1
      resources/views/auth/passwords/reset.blade.php
  79. 3
      resources/views/auth/register.blade.php
  80. 9
      resources/views/layouts/app.blade.php
  81. 8
      resources/views/layouts/footer.blade.php
  82. 17
      resources/views/layouts/main-nav.blade.php
  83. 15
      resources/views/layouts/nav-buttons.blade.php
  84. 69
      resources/views/profile/_profile-card.blade.php
  85. 37
      resources/views/profile/_table-list.blade.php
  86. 2
      resources/views/profile/edit-account.blade.php
  87. 3
      resources/views/profile/edit-profile.blade.php
  88. 89
      resources/views/profile/view.blade.php
  89. 5
      resources/views/table/_header-handle.blade.php
  90. 9
      resources/views/table/_panel-metadata.blade.php
  91. 4
      resources/views/table/_rows.blade.php
  92. 16
      resources/views/table/_sort-dropdown.blade.php
  93. 10
      resources/views/table/_table-subpage-header.blade.php
  94. 26
      resources/views/table/_view-action-buttons.blade.php
  95. 3
      resources/views/table/conf.blade.php
  96. 20
      resources/views/table/create.blade.php
  97. 44
      resources/views/table/favourites.blade.php
  98. 50
      resources/views/table/propose/add-rows-csv.blade.php
  99. 56
      resources/views/table/propose/add-rows.blade.php
  100. 12
      resources/views/table/propose/edit-rows.blade.php
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,11 @@
{
"extends": "standard",
"globals": {
"$": false,
"Vue": false,
"_": false
},
"rules": {
"camelcase": ["off", { "properties": "never" }]
}
}

@ -10,3 +10,7 @@ ana:
npm run dev-analyze
prod:
npm run prod
bc:
php artisan view:clear
php artisan cache:clear
php artisan ide-helper:generate

@ -13,6 +13,7 @@ interface RowData {}
* @property bool $_new - row is new in the changeset
* @property bool $_remove - marked to be removed
* @property mixed[] $_orig - original values before transformation, key by CID
* @property mixed[] $_loadvals - values after transformation, key by CID / for use by JS to detect changes since page load
* @property string[] $_changed - values that were changed
*/
interface DecoratedRow extends RowData {}

@ -12,7 +12,7 @@ class ConfirmUser extends Command
*
* @var string
*/
protected $signature = 'user:confirm {user}';
protected $signature = 'user:confirm {user} {--undo}';
/**
* The console command description.
@ -39,7 +39,15 @@ class ConfirmUser extends Command
public function handle()
{
$u = User::resolve($this->argument('user'));
$un='';
if ($this->option('undo')) {
$u->update(['confirmed' => false]);
$un='un';
} else {
$u->update(['confirmed' => true]);
$this->info("User #$u->id with e-mail $u->email and handle @$u->name was confirmed.");
}
$this->info("User #$u->id with e-mail $u->email and handle @$u->name was {$un}confirmed.");
}
}

@ -20,17 +20,22 @@ class DashController extends Controller
return back();
}
$users = User::orderBy('updated_at', 'desc')
$data['users'] = User::orderBy('updated_at', 'desc')
->withCount(['tables'])
->paginate(15, ['id', 'title', 'name', 'tables_count'], 'pageu');
->paginate(10, ['id', 'title', 'name', 'tables_count'], 'userPage')
->appends(\Input::except('userPage'));
// TODO visit counting
$tables = Table::forList()
->orderBy('visits', 'desc')
->paginate(10, ['*'], 'paget');
$sort = Table::resolveSortType(\Input::get('tableSort'));
$data['sortOptions'] = Table::getSortOptions();
$showGreeter = !$dismiss && !$request->cookie('dismiss-greeter', false);
$data['tables'] = Table::forList()->tableSort($sort)
->paginate(10, ['*'], 'tablePage')
->appends(\Input::except('tablePage'));
return view('welcome', compact('users', 'tables', 'showGreeter'));
$data['tableSort'] = $sort;
$data['showGreeter'] = !$dismiss && !$request->cookie('dismiss-greeter', false);
return view('welcome', $data);
}
}

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Table;
use App\Models\User;
use Illuminate\Http\Request;
@ -11,6 +12,18 @@ use Illuminate\Http\Request;
*/
class ProfileController extends Controller
{
private function profileTableListPrepareData(User $user)
{
$data['user'] = $user;
$data['tables_count'] = $user->tables()->count();
$data['favourites_count'] = $user->favouriteTables()->count();
$data['tableSort'] = Table::resolveSortType(\Input::get('tableSort'));
$data['sortOptions'] = Table::getSortOptions();
return $data;
}
/**
* Show the user's profile / dashboard.
*
@ -18,14 +31,34 @@ class ProfileController extends Controller
*/
public function view(User $user)
{
$tables = $user->tables()->forList()->orderByDesc('updated_at')->paginate(10);
$tables_count = $user->tables()->count();
return view('profile.view')->with(compact(
'tables',
'user',
'tables_count'
));
$data = $this->profileTableListPrepareData($user);
$data['pageVariant'] = 'tables';
$data['tables'] = $user->tables()->forList()
->tableSort($data['tableSort'])
->paginate(10);
return view('profile.view', $data);
}
/**
* Show the user's profile / dashboard.
*
* @return \Illuminate\View\View
*/
public function viewFavourites(User $user)
{
$data = $this->profileTableListPrepareData($user);
$data['pageVariant'] = 'favourites';
$data['tables'] = $user->favouriteTables()->forList()
->tableSort($data['tableSort'])
->paginate(10);
$data['showAuthors'] = true;
return view('profile.view', $data);
}
/**

@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers;
use App\Models\Table;
use App\Models\User;
use XMLWriter;
/**
* This controller is responsible for producing a XML sitemap
*/
class SitemapController extends Controller
{
/**
* Render a sitemap to the browser
*/
public function sitemap()
{
ob_end_clean();
// Redirect output to a client’s web browser
header("Content-Type: text/xml; charset=utf-8");
// Cache headers
header('Cache-Control: max-age=0');
// IE9
header('Cache-Control: max-age=1');
// Other IE headers
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); // always modified
header('Cache-Control: cache, must-revalidate'); // HTTP/1.1
header('Pragma: no-cache'); // HTTP/1.0
$xmlWriter = new XMLWriter();
$xmlWriter->openURI('php://output');
$xmlWriter->startDocument('1.0', 'UTF-8');
$xmlWriter->setIndent(true);
{
$xmlWriter->startElement('urlset');
{
$xmlWriter->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
foreach ($this->iterateTables() as $entry) {
$xmlWriter->startElement('url');
$xmlWriter->writeElement('loc', $entry->url);
if (isset($entry->priority)) {
$xmlWriter->writeElement('priority', $entry->priority);
}
if (isset($entry->changefreq)) {
$xmlWriter->writeElement('changefreq', $entry->changefreq);
}
if (isset($entry->lastmodified)) {
$xmlWriter->writeElement('lastmod', $entry->lastmodified->format(\DateTime::W3C));
}
$xmlWriter->endElement();
}
}
$xmlWriter->endElement();
}
$xmlWriter->endDocument();
}
/**
* Go through all tables and other links and return objects
* that can be turned into sitemap entries.
*
* @return \Generator|object[]
*/
private function iterateTables()
{
$prio_info = .9;
$prio_user = .8;
$prio_table = .7;
$prio_table_rev = .6;
$prio_misc = .5;
yield (object)[
'url' => route('dash'),
'priority' => 1,
];
yield (object)[
'url' => route('terms'),
'priority' => $prio_info,
];
yield (object)[
'url' => route('faq'),
'priority' => $prio_info,
];
yield (object)[
'url' => route('donate'),
'priority' => $prio_info,
];
yield (object)[
'url' => route('privacy'),
'priority' => $prio_info,
];
yield (object)[
'url' => route('login'),
'priority' => $prio_misc,
];
yield (object)[
'url' => route('register'),
'priority' => $prio_misc,
];
// User profiles
foreach (User::all() as $user) {
yield (object)[
'url' => route('profile.view', $user->name),
'lastmodified' => $user->updated_at,
'priority' => $prio_user,
];
}
// Tables
foreach (Table::all() as $table) {
yield (object)[
'url' => $table->viewRoute,
'lastmodified' => $table->updated_at,
'priority' => $prio_table,
];
yield (object)[
'url' => $table->revisionsRoute,
'lastmodified' => $table->updated_at,
'priority' => $prio_table_rev,
];
}
}
}

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Revision;
use App\Models\Row;
use App\Models\Table;
use App\Models\User;
use App\Tables\Changeset;
@ -14,13 +15,84 @@ use App\Tables\CXMacroExporter;
use App\Tables\JsonExporter;
use App\Tables\PhpExporter;
use App\Tables\RowNumerator;
use DB;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use MightyPork\Exceptions\NotApplicableException;
use MightyPork\Exceptions\SimpleValidationException;
use MightyPork\Utils\Utils;
/**
* Table view, creation, settings
*/
class TableController extends Controller
{
/**
* Helper to fetch a table by user and table name
*
* @param Request $request
* @param User $user
* @param string $table
* @return Table
*/
private function resolveTable(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
return $tableModel;
}
/**
* Favourite a table
*/
public function favouriteTable(Request $request, User $user, string $table)
{
$tableModel = $this->resolveTable($request, $user, $table);
$self = \user();
if (! $self->favouritesTable($tableModel)) {
$self->favouriteTables()->attach($tableModel);
}
return redirect($tableModel->viewRoute);
}
/**
* Un-favourite a table
*/
public function unfavouriteTable(Request $request, User $user, string $table)
{
$tableModel = $this->resolveTable($request, $user, $table);
\user()->favouriteTables()->detach($tableModel);
return redirect($tableModel->viewRoute);
}
/**
* Switch table's current revision
*/
public function revertTo(Request $request, User $user, string $table)
{
$input = $this->validate($request, [
'rev' => 'int'
]);
$tableModel = $this->resolveTable($request, $user, $table);
$this->authorize('edit', $tableModel);
$revisionNum = (int)$input->rev;
$revision = $tableModel->revisions()->orderBy('created_at')->skip($revisionNum - 1)->first();
if ($revision === null) abort(404, "No such revision");
$tableModel->revision()->associate($revision);
$tableModel->save();
return redirect($tableModel->revisionsRoute);
}
/**
* Show a table
*/
public function view(Request $request, User $user, string $table)
{
$input = $this->validate($request, [
@ -28,14 +100,15 @@ class TableController extends Controller
]);
/** @var Table $tableModel */
$tableModel = $user->tables()->withCount(['favourites', 'forks', 'revisions', 'comments', 'proposals'])
$tableModel = $user->tables()->withCount(['favourites', 'forks', 'revisions', 'comments'])
->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
// option to show other revisions
$revisionNum = $tableModel->revisions_count;
if ($input->has('rev')) {
$rev = (int)$input->rev;
$revision = $tableModel->revisions()->orderBy('created_at')->skip($rev - 1)->first();
$revisionNum = (int)$input->rev;
$revision = $tableModel->revisions()->orderBy('created_at')->skip($revisionNum - 1)->first();
if ($revision === null) abort(404, "No such revision");
} else {
$revision = $tableModel->revision;
@ -45,21 +118,27 @@ class TableController extends Controller
$columns = Column::columnsFromJson($revision->columns);
$rows = $revision->rowsData($columns)->paginate(25, []);
$rq = $revision->rowsData($columns);
if (count($columns)) $rq = $rq->sortByCol($columns[0]);
$rows = $rq->paginate(25, []);
Row::disableCasts();
return view('table.view', [
'table' => $tableModel,
'revision' => $revision,
'revisionNum' => $revisionNum,
'proposals_count' => $tableModel->proposals()->unmerged($tableModel)->count(),
'columns' => $columns,
'rows' => $rows,
]);
}
/**
* Delete a table
*/
public function delete(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$tableModel = $this->resolveTable($request, $user, $table);
$this->authorize('delete', $tableModel);
if ($request->get('table-name', null) !== $table) {
@ -72,22 +151,14 @@ class TableController extends Controller
}
/**
* SHow a form for creating a new table
*
* @return \Illuminate\Http\Response
* Show a form for creating a new table
*/
public function create()
{
$exampleData =
"Mercenaria mercenaria,hard clam,40\n" .
"Magallana gigas,pacific oyster,30\n" .
"Patella vulgata,common limpet,20";
$exampleData = "";
$columns = Column::columnsFromJson([
// fake 'id' to satisfy the check in Column constructor
['name' => 'latin', 'type' => 'string', 'title' => 'Latin Name'],
['name' => 'common', 'type' => 'string', 'title' => 'Common Name'],
['name' => 'lifespan', 'type' => 'int', 'title' => 'Lifespan (years)']
['id' => 1, 'name' => 'column_1', 'type' => 'string', 'title' => 'First Column'],
]);
return view('table.create', [
@ -102,9 +173,7 @@ class TableController extends Controller
*/
public function settings(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$tableModel = $this->resolveTable($request, $user, $table);
$this->authorize('edit', $tableModel);
return view('table.conf', [
@ -117,16 +186,13 @@ class TableController extends Controller
*/
public function storeSettings(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$tableModel = $this->resolveTable($request, $user, $table);
$this->authorize('edit', $tableModel);
$input = $this->validate($request, [
'name' => [
'required',
VALI_NAME,
Rule::unique('tables')->ignoreModel($tableModel),
],
'title' => ['required', VALI_LINE],
'description' => ['nullable', VALI_TEXT],
@ -134,6 +200,11 @@ class TableController extends Controller
'origin' => ['nullable', VALI_TEXT],
]);
$otherTableNames = $user->tables()->get(['name'])->pluck('name')->diff([$table])->all();
if (in_array($input->name, $otherTableNames)) {
return $this->backWithErrors(['name' => "You already have a table called \"$input->name\""]);
}
$tableModel->fill($input->all());
$tableModel->save();
@ -142,6 +213,39 @@ class TableController extends Controller
return redirect($tableModel->viewRoute); // the route now changed
}
/**
* List of table revisions
*/
public function viewRevisions(Request $request, User $user, string $table)
{
$tableModel = $this->resolveTable($request, $user, $table);
$revisions = $tableModel->revisions()->orderBy('created_at', 'desc')->get();
return view('table.revisions', [
'table' => $tableModel,
'revisions' => $revisions,
]);
}
/**
* List of table favouriting users
*/
public function viewFavourites(Request $request, User $user, string $table)
{
$tableModel = $this->resolveTable($request, $user, $table);
$users = $tableModel->favouritingUsers()->orderBy('title')->get();
return view('table.favourites', [
'table' => $tableModel,
'users' => $users,
]);
}
/**
* Store a newly created table (save form)
*/
public function storeNew(Request $request)
{
/** @var User $u */
@ -151,7 +255,6 @@ class TableController extends Controller
'name' => [
'required',
VALI_NAME,
Rule::unique('tables'),
],
'title' => ['required', VALI_LINE],
'description' => ['nullable', VALI_TEXT],
@ -164,7 +267,7 @@ class TableController extends Controller
// Check if table name is unique for user
if ($u->hasTable($input->name)) {
return $this->backWithErrors([
'name' => "A table called \"$input->name\" already exists in your account.",
'name' => "You already have a table called \"$input->name\"",
]);
}
@ -207,13 +310,39 @@ class TableController extends Controller
}
// --- DATA ---
$dataCsvLines = Utils::csvToArray($input->data);
$fname = 'csv-file';
$dataCsvLines = [];
if ($request->hasFile($fname)) {
try {
$file = $request->file($fname);
if ($file->isValid() && $file->isReadable()) {
$handle = $file->openFile();
$dataCsvLines = Utils::csvToArray($handle);
if (empty($dataCsvLines)) throw new \Exception("Failed to parse CSV file.");
$handle = null;
} else {
throw new \Exception("File not valid.");
}
} catch (\Exception $e) {
return $this->backWithErrors(['csv-file' => $e->getMessage()]);
}
}
else if ($input->data) {
try {
$text = trim($input->data);
if (!empty($text)) {
$dataCsvLines = Utils::csvToArray($text);
}
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}
}
// Preparing data to insert into the Rows table
$rowsToInsert = null;
$rowNumerator = null;
try {
$rowsToInsert = Changeset::csvToRowsArray($columns, $dataCsvLines, true)->all();
$rowsToInsert = (new Changeset)->csvToRowsArray($columns, $dataCsvLines, false)->all();
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}
@ -240,17 +369,32 @@ class TableController extends Controller
/** @var Table $table */
$table = null;
\DB::transaction(function () use ($revisionFields, $tableFields, $rowsToInsert, &$table) {
$revision = Revision::create($revisionFields);
$newRevision = Revision::create($revisionFields);
$tableFields['revision_id'] = $revision->id; // current revision (not-null constraint on this FK)
$tableFields['revision_id'] = $newRevision->id; // current revision (not-null constraint on this FK)
$table = Table::create($tableFields);
// Attach the revision to the table, set as current
$table->revisions()->attach($revision);
$table->revision()->associate($revision);
$table->revisions()->attach($newRevision);
$table->revision()->associate($newRevision);
// Spawn rows, linked to the revision
$revision->rows()->createMany($rowsToInsert);
if (!empty($rowsToInsert)) {
// this replicates the code in Proposal->toRevision
$conn = DB::connection();
$prepared = $conn->getPdo()->prepare('INSERT INTO rows (data) VALUES (?)');
foreach (array_chunk($rowsToInsert, 10000) as $i => $chunk) {
$ids = [];
foreach ($chunk as $newRow) {
$prepared->execute([json_encode($newRow)]);
$ids[] = $conn->getPdo()->lastInsertId();
}
$qms = rtrim(str_repeat('('.$newRevision->id.', ?),', count($ids)), ',');
$conn->statement('INSERT INTO revision_row_pivot (revision_id, row_id) VALUES '.$qms.';',
$ids);
}
}
});
return redirect($table->viewRoute);
@ -284,9 +428,7 @@ class TableController extends Controller
*/
public function export(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$tableModel = $this->resolveTable($request, $user, $table);
$exporter = null;

@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\Proposal;
use App\Models\Row;
use App\Models\Table;
use App\Models\User;
use App\Tables\Changeset;
@ -11,6 +13,9 @@ use Illuminate\Support\Facades\Input;
use MightyPork\Exceptions\SimpleValidationException;
use MightyPork\Utils\Utils;
/**
* Draft composing
*/
class TableEditController extends Controller
{
/**
@ -34,9 +39,14 @@ class TableEditController extends Controller
});
}
private function storeChangeset(Changeset $chs)
/**
* Store the changeset to session
*
* @param Changeset $changeset
*/
private function storeChangeset(Changeset $changeset)
{
session()->put($chs->table->draftSessionKey, $chs);
session()->put($changeset->table->draftSessionKey, $changeset);
}
/**
@ -50,9 +60,19 @@ class TableEditController extends Controller
session()->forget($tableModel->draftSessionKey);
return redirect($tableModel->viewRoute);
return redirect($tableModel->draftRoute);
}
#region Draft tabs
/**
* Show the table edit view with tabs
*
* @param User $user - table owner
* @param string $table - table name
* @param string|null $tab - page tab name, kebab-case
* @return \Illuminate\View\View;
*/
public function draft(User $user, string $table, $tab = null)
{
/** @var Table $tableModel */
@ -60,8 +80,9 @@ class TableEditController extends Controller
if ($tableModel === null) abort(404, "No such table.");
if ($tab == null) $tab = 'edit-rows';
$tabs = ['edit-rows', 'add-rows', 'manage-columns', 'review'];
if (!in_array($tab, $tabs)) abort(404, "No such tab: $tab");
$method = camel_case('tab-'.$tab);
if (!method_exists($this, $method)) abort(404, "No such tab: $tab");
$changeset = $this->getChangeset($tableModel);
@ -69,16 +90,19 @@ class TableEditController extends Controller
dd($changeset);
}
return $this->{camel_case($tab)}($changeset);
return $this->$method($changeset);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function editRows(Changeset $changeset)
private function tabEditRows(Changeset $changeset)
{
$revision = $changeset->revision;
$columns = $changeset->fetchAndTransformColumns();
$rows = $revision->rowsData($columns, true, false)->paginate(25, []);
$rows = $revision->rowsData($columns, true, false)
->sortByCol($changeset->fetchRevisionColumns()[0])
->paginate(25, []);
Row::disableCasts();
return view('table.propose.edit-rows', [
'changeset' => $changeset,
'table' => $changeset->table,
@ -88,7 +112,7 @@ class TableEditController extends Controller
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function addRows(Changeset $changeset)
private function tabAddRows(Changeset $changeset)
{
$columns = $changeset->fetchAndTransformColumns();
$rows = $changeset->getAddedRows(25);
@ -101,6 +125,50 @@ class TableEditController extends Controller
]);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function tabAddRowsCsv(Changeset $changeset)
{
$columns = $changeset->fetchAndTransformColumns();
return view('table.propose.add-rows-csv', [
'changeset' => $changeset,
'table' => $changeset->table,
'columns' => collect($columns)->where('toRemove', false),
]);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function tabManageColumns(Changeset $changeset)
{
$columns = $changeset->fetchAndTransformColumns();
return view('table.propose.manage-columns', [
'changeset' => $changeset,
'table' => $changeset->table,
'columns' => collect($columns),
]);
}
/** @noinspection PhpUnusedPrivateMethodInspection */
private function tabReview(Changeset $changeset)
{
return view('table.propose.review', [
'changeset' => $changeset,
'table' => $changeset->table,
]);
}
#endregion
/**
* API hook called by AJAX or via forms.
* Generally applies modifications to the Changeset stored in session.
*
* @param Request $request
* @param User $user
* @param string $table
* @return \Symfony\Component\HttpFoundation\Response
*/
public function draftUpdate(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
@ -111,14 +179,54 @@ class TableEditController extends Controller
$input = objBag($request->all(), false);
try {
$resp = null;
$code = 200;
try {
switch ($input->action) {
case 'row.update':
$data = (object)$input->data;
$changeset->rowUpdate($data);
$resp = $changeset->rowUpdate($data);
break;
$resp = $changeset->fetchAndTransformRow($data->_id);
case 'row.update-many':
$newVals = $input->data;
$updated = [];
foreach ($newVals as $rowUpdate) {
$r = $changeset->rowUpdate($rowUpdate);
$updated[$r->_id] = $r;
}
$resp = $updated;
break;
case 'row.add-csv':
$fname = 'csv-file';
if ($request->hasFile($fname)) {
try {
$file = $request->file($fname);
if ($file->isValid() && $file->isReadable()) {
$handle = $file->openFile();
$csv = Utils::csvToArray($handle);
if (empty($csv)) throw new \Exception("Failed to parse CSV file.");
$changeset->addFilledRows($csv);
$handle = null;
} else {
throw new \Exception("File not valid.");
}
} catch (\Exception $e) {
return $this->backWithErrors(['csv-file' => $e->getMessage()]);
}
}
else {
try {
$text = trim($input->data);
if (empty($text)) throw new \Exception("Empty CSV field and no file uploaded.");
$changeset->addFilledRows(Utils::csvToArray($text));
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}
}
break;
case 'row.remove':
@ -127,6 +235,10 @@ class TableEditController extends Controller
$resp = $isNew ? null : $changeset->fetchAndTransformRow($input->id);
break;
case 'row.remove-empty-new':
$changeset->removeEmptyNewRows();
break;
case 'row.restore':
$changeset->rowRestore($input->id);
$resp = $changeset->fetchAndTransformRow($input->id);
@ -134,28 +246,64 @@ class TableEditController extends Controller
case 'rows.add':
$changeset->addBlankRows($input->count);
break;
// rows.add is sent via a form
if ($input->has('redirect')) {
return redirect($input->redirect);
} else {
$resp = null;
}
case 'col.update':
$data = (object)$input->data;
$resp = $changeset->columnUpdate($data);
break;
case 'rows.add-csv':
try {
$changeset->addFilledRows(Utils::csvToArray($input->data));
} catch (\Exception $e) {
return $this->backWithErrors(['data' => $e->getMessage()]);
}
case 'col.remove':
$isNew = $changeset->isNewColumn($input->id);
$changeset->columnRemove($input->id);
$resp = $isNew ? null : $changeset->fetchAndTransformColumn($input->id);
break;
// rows.add-csv is sent via a form
if ($input->has('redirect')) {
return redirect($input->redirect);
} else {
$resp = null;
}
case 'col.restore':
$changeset->columnRestore($input->id);
$resp = $changeset->fetchAndTransformColumn($input->id);
break;
case 'col.add':
$resp = $changeset->addBlankCol();
break;
case 'col.sort':
$changeset->setColOrder($input->order);
$resp = !empty($changeset->columnOrder); // return flag if order is changed
break;
case 'note.set':
$changeset->note = $input->text;
break;
case 'reset.col-order': // called via POST or GET
$changeset->resetColumnOrder();
$resp = $changeset->fetchAndTransformColumns(); // return all columns
break;
case 'reset.col-remove': // called via GET
$changeset->resetRemovedColumns();
break;
case 'reset.col-add': // called via GET
$changeset->resetAddedColumns();
break;
case 'reset.col-update': // called via GET
$changeset->resetUpdatedColumns();
break;
case 'reset.row-remove': // called via GET
$changeset->resetRemovedRows();
break;
case 'reset.row-add': // called via GET
$changeset->resetAddedRows();
break;
case 'reset.row-update': // called via GET
$changeset->resetUpdatedRows();
break;
default:
@ -169,6 +317,56 @@ class TableEditController extends Controller
$this->storeChangeset($changeset);
if ($request->method() == 'GET') {
return back();
}
// Redirect requested via form
if ($code == 200 && $input->has('redirect')) {
return redirect($input->redirect);
}
return $this->jsonResponse($resp, $code);
}
/**
* Submit the form
*
* @param Request $request
* @param User $user
* @param string $table
* @return \Symfony\Component\HttpFoundation\Response
*/
public function draftSubmit(Request $request, User $user, string $table)
{
/** @var Table $tableModel */
$tableModel = $user->tables()->where('name', $table)->first();
if ($tableModel === null) abort(404, "No such table.");
$input = $this->validate($request, [
'note' => 'required|string'
]);
$changeset = $this->getChangeset($tableModel);
$changeset->note = trim($input->note);
if (empty($changeset->note)) throw new SimpleValidationException('note', 'Note is required.');
if (!$changeset->getRowChangeCounts()->any && !$changeset->getColumnChangeCounts()->any) {
flash()->error("There are no changes to submit.");
return back();
}
$proposal = Proposal::fromChangeset($changeset);
$proposal->saveOrFail();
if (\user()->ownsTable($tableModel)) {
$proposal->createRevision();
} else {
// TODO send a notification to the table owner
}
session()->forget($tableModel->draftSessionKey);
return redirect($tableModel->viewRoute);
}
}

@ -35,7 +35,6 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
'throttle:120,15', // try to prevent people refresh-spamming the server to game table visit counts
EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,

@ -4,6 +4,9 @@ namespace App\Models;
use App\Models\Concerns\Reportable;
use App\Tables\Changeset;
use App\Tables\Column;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use MightyPork\Exceptions\NotApplicableException;
/**
@ -16,7 +19,7 @@ use MightyPork\Exceptions\NotApplicableException;
* @property int $revision_id
* @property int $author_id
* @property string $note
* @property Proposal $changes - JSONB
* @property Changeset $changeset - JSONB
* @property-read User $author
* @property-read Table $table
* @property-read Revision $revision
@ -46,17 +49,30 @@ class Proposal extends BaseModel
return $this->belongsTo(Table::class);
}
public function getChangesAttribute($value)
public function scopeUnmerged(Builder $query, Table $table)
{
$changeset = Changeset::fromObject(fromJSON($value));
return $query->whereRaw('
"id" NOT IN
(SELECT "proposal_id" FROM "revisions"
LEFT JOIN "table_revision_pivot"
ON "revisions"."id" = "table_revision_pivot"."revision_id"
WHERE "table_revision_pivot"."table_id" = ?)',
[
$table->getKey()
]);
}
public function getChangesetAttribute()
{
$changeset = Changeset::fromObject(fromJSON($this->attributes['changes'], true));
$changeset->revision = $this->revision;
$changeset->table = $this->table;
$changeset->table = $this->getAttribute('table');
$changeset->note = $this->note;
return $changeset;
}
public function setChangesAttribute($value)
public function setChangesetAttribute($value)
{
if ($value instanceof Changeset) {
$this->attributes['changes'] = toJSON($value->toObject());
@ -79,7 +95,7 @@ class Proposal extends BaseModel
throw new NotApplicableException('No changes to propose.');
}
if ($changeset->note == null) {
if (empty($changeset->note)) {
throw new NotApplicableException('Proposal note must be filled.');
}
@ -91,14 +107,113 @@ class Proposal extends BaseModel
throw new NotApplicableException('Revision not assigned to Changeset');
}
// Assign unique row IDs to new rows (they use temporary negative IDs in a draft)
$changeset->renumberRows();
return new Proposal([
// relations
'table_id' => $changeset->table->getKey(),
'revision_id' => $changeset->revision->getKey(),
'author_id' => \Auth::user()->getKey(),
'author_id' => \user()->getKey(),
// the proposal info
'note' => $changeset->note,
'changes' => $changeset->toObject(),
'changeset' => $changeset, // this is without a note
]);
}
/**
* Accept the proposed changes: create a new revision based on the parent revision
* and changes described in this Proposal, and link it to the table.
*/
public function createRevision()
{
DB::transaction(function () {
$changeset = $this->changeset;
$columns = $changeset->fetchAndTransformColumns();
$newRevision = new Revision([
'ancestor_id' => $changeset->revision->getKey(),
'proposal_id' => $this->getKey(),
'note' => $changeset->note,
'row_count' => 0, // Will be set later when we are sure about the row count
'columns' => array_values(array_filter(array_map(function(Column $c) {
if ($c->toRemove) return null;
return $c->toArray(false);
}, $columns))),
]);
$newRevision->save(); // this gives it an ID, needed to associate rows
// --- Copy over rows that are left unchanged ---
// ...this directly works with the pivot
$removedRowIds = (array)($changeset->removedRows ?? []);
$changedRowIds = array_keys((array)($changeset->rowUpdates ?? []));
$excludedGRIDs = array_merge($removedRowIds, $changedRowIds);
$excluded_ids = [];
if ($excludedGRIDs) {
$questionmarks = str_repeat('?,', count($excludedGRIDs) - 1) . '?';
$excluded_ids = $changeset->revision->rows()->whereRaw("data->'_id' IN ($questionmarks)", $excludedGRIDs)
->get(['id'])->pluck('id')->toArray();
}
$query = '
INSERT INTO revision_row_pivot
SELECT ? as revision_id, row_id FROM revision_row_pivot
WHERE revision_id = ?';
$subs = [
$newRevision->getKey(),
$changeset->revision->getKey()
];
if ($excluded_ids) {
$questionmarks = str_repeat('?,', count($excluded_ids) - 1) . '?';
$query .= ' AND row_id NOT IN (' . $questionmarks . ')';
$subs = array_merge($subs, $excluded_ids);
}
DB::statement($query, $subs);
// --- Insert modified rows ---
if ($changeset->rowUpdates) {
$ids = array_keys($changeset->rowUpdates);
$questionmarks = str_repeat('?,', count($ids) - 1) . '?';
$toChange = $changeset->revision->rows()->whereRaw("data->'_id' IN ($questionmarks)", $ids)->get();
$updateData = [];
foreach ($toChange as $row) {
$updateData[] = new Row([
'data' => $changeset->transformRow($row->data, false),
]);
}
$newRevision->rows()->saveMany($updateData);
}
// --- Insert new rows ---
if ($changeset->newRows) {
$conn = DB::connection();
$prepared = $conn->getPdo()->prepare('INSERT INTO rows (data) VALUES (?)');
foreach (array_chunk($changeset->newRows, 10000) as $i => $chunk) {
$ids = [];
foreach ($chunk as $newRow) {
$prepared->execute([json_encode($newRow)]);
$ids[] = $conn->getPdo()->lastInsertId();
}
$qms = rtrim(str_repeat('('.$newRevision->id.', ?),', count($ids)), ',');
$conn->statement('INSERT INTO revision_row_pivot (revision_id, row_id) VALUES '.$qms.';',
$ids);
}
}
$newRevision->update(['row_count' => $newRevision->rows()->count()]);
// --- Attach this revision to the table ---
$changeset->table->revisions()->save($newRevision);
$changeset->table->update(['revision_id' => $newRevision->getKey()]);
});
}
}

@ -13,12 +13,13 @@ use Riesjart\Relaquent\Model\Concerns\HasRelaquentRelationships;
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property int $ancestor_id
* @property int $proposal_id
* @property string $note
* @property object $columns
* @property int $row_count - cached number of rows in the revision
* @property-read Revision|null $parentRevision
* @property-read Row[]|Collection $rows
* @property-read Proposal|null $sourceProposal - proposal that was used to create this revision
* @property-read Proposal|null $proposal - proposal that was used to create this revision
* @property-read Proposal[]|Collection $dependentProposals
*/
class Revision extends BaseModel
@ -45,22 +46,22 @@ class Revision extends BaseModel
$selects = $withId ? ["data->>'_id' as _id"] : [];
foreach ($columns as $col) {
$selects[] = "data->>'$col->id' as " . ($named ? $col->name : $col->id);
$selects[] = "data->>'$col->id' as \"" . ($named ? $col->name : $col->id)."\"";
}
return $this->rows()->select([])->selectRaw(implode(', ', $selects));
}
/** Proposal that lead to this revision */
public function sourceProposal()
public function proposal()
{
return $this->hasOneThrough(Proposal::class, 'revision_proposal_pivot');
return $this->belongsTo(Proposal::class, 'proposal_id');
}
/** Proposals that depend on this revision */
public function dependentProposals()
{
return $this->hasMany(Proposal::class);
return $this->hasMany(Proposal::class, 'revision_id');
}
/** Revision this orignates from */

@ -2,6 +2,9 @@
namespace App\Models;
use App\Tables\Column;
use Illuminate\Database\Eloquent\Builder;
/**
* Row in a data table
*
@ -10,6 +13,9 @@ namespace App\Models;
*/
class Row extends BaseModel
{
protected static $noCasts = false;
protected static $noCastsStack = [];
protected $casts = [
'data' => 'object',
];
@ -17,6 +23,35 @@ class Row extends BaseModel
protected $guarded = [];
public $timestamps = false;
/**
* Disable casts, pushing the previous value onto a stack
*
* Needed when using the rowsData() revision method that could
* cause a name collision with a database column, confusing Eloquent
* into applying incorrect casts.
*/
public static function disableCasts()
{
array_push(self::$noCastsStack, self::$noCasts);
self::$noCasts = true;
}
/**
* Restore the original value of casts enabled/disabled
*/
public static function enableCasts()
{
self::$noCasts = array_pop(self::$noCastsStack);
}
public function getCasts()
{
// This override is needed to prevent casting 'id' to int when it occurs
// as a field in a result of the rowData query
// (this caused display of zeros in string cols)
return self::$noCasts ? [] : $this->casts;
}
public function getRowIdAttribute() {
return $this->data->_id;
}
@ -25,6 +60,38 @@ class Row extends BaseModel
$this->data->_id = $value;
}
public function scopeSortByCol(Builder $query, Column $column, $dir="asc")
{
$q = "data->>'".$column->id."'";
$dir = strtoupper($dir);
switch ($column->type) {
case 'int':
$q = "CAST($q AS INTEGER) $dir";
break;
case 'float':
$q = "CAST($q AS FLOAT) $dir";
break;
case 'string':
// Without a size restriction and a cast, this query becomes VERY slow
$q = "CAST($q AS CHAR(25)) $dir, data->>'_id' $dir";
// added _id as a fallback to ensure the order is the same between revisions
break;
case 'bool':
$q = "CAST($q AS boolean) $dir";
break;
default:
throw new \LogicException("Missing SQL cast for column type $column->type, performance will suffer.");
}
return $query->orderByRaw($q);
}
/**
* Allocate a single Global Row ID, application-unique.
*
@ -48,6 +115,7 @@ class Row extends BaseModel
*/
public static function allocateRowIDs($count)
{
if ($count == 0) return null;
$last = \DB::selectOne("SELECT multi_nextval('global_row_id_seq', ?) AS last_id;", [$count])->last_id;
return [$last - $count + 1, $last];
}

@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\Concerns\Reportable;
use function GuzzleHttp\Psr7\build_query;
use http\QueryString;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@ -28,6 +30,8 @@ use Illuminate\Database\Eloquent\Collection;
* @property-read string $draftSessionKey
* @property-read string $draftDiscardRoute
* @property-read string $draftUpdateRoute
* @property-read string $draftSubmitRoute
* @property-read string $revisionsRoute
* @property-read User $owner
* @property-read Table $parentTable
* @property-read Table[]|Collection $forks
@ -141,14 +145,40 @@ class Table extends BaseModel
case 'deleteRoute': return route('table.delete', $arg);
case 'draftDiscardRoute': return route('table.draft-discard', $arg);
case 'draftUpdateRoute': return route('table.draft-update', $arg);
case 'draftSubmitRoute': return route('table.draft-submit', $arg);
case 'revisionsRoute': return route('table.revisions', $arg);
}
}
if ($name == 'handle') return '@' . $this->cachedOwner()->name . '/' . $this->name;
if ($name == 'draftSessionKey') return "proposal.{$this->id}";
return parent::__get($name);
}
/**
* Get route to some action (so we dont have to add xxRoute for everything)
*
* @param $action
* @param array $getargs
* @return string
*/
public function actionRoute($action, $getargs = [])
{
$user = $this->cachedOwner()->name;
$table = $this->name;
$base = "/@$user/$table";
if ($action != 'view') $base .= "/$action";
if ($getargs) {
return $base . '?' . build_query($getargs);
} else {
return $base;
}
}
public function getDraftRoute($tab=null)
{
return route('table.draft', [
@ -160,7 +190,72 @@ class Table extends BaseModel
public function scopeForList(Builder $query)
{
return $query->with('revision:id,row_count')->with('owner:id,name,title')
->withCount(['favourites', 'forks', 'revisions', 'proposals']);
return $query->with('revision:id,row_count')
->with('owner:id,name,title')
->withCount(['favourites', 'forks', 'revisions', 'proposals', 'comments']);
}
public static function getSortOptions()
{
return [
'most-visited' => 'Most Visited',
'last-updated' => 'Last Updated',
// 'most-discussed' => 'Most Discussed',
'most-liked' => 'Most Liked',
'newest' => 'Newest',
'oldest' => 'Oldest',
'most-rows' => 'Longest',
'least-rows' => 'Shortest',
'most-updated' => 'Most Changed',
];
}
public static function resolveSortType($sort)
{
$defaultSort = 'last-updated';
if (!in_array($sort, array_keys(self::getSortOptions()))) {
$sort = $defaultSort;
}
return $sort;
}
public function scopeTableSort(Builder $query, $sort)
{
switch ($sort) {
case 'most-visited':
$query = $query->orderBy('visits', 'desc');
break;
case 'last-updated':
$query = $query->orderBy('updated_at', 'desc');
break;
case 'most-updated':
$query = $query->orderBy('revisions_count', 'desc');
break;
case 'most-discussed':
$query = $query->orderBy('comments_count', 'desc');
break;
case 'most-liked':
$query = $query->orderBy('favourites_count', 'desc');
break;
case 'most-rows':
$query = $query
->join('revisions', 'revisions.id', '=', 'tables.revision_id')
->orderBy('revisions.row_count', 'desc');
break;
case 'least-rows':
$query = $query
->join('revisions', 'revisions.id', '=', 'tables.revision_id')
->orderBy('revisions.row_count', 'asc');
break;
case 'newest':
$query = $query->orderBy('created_at', 'desc');
break;
case 'oldest':
$query = $query->orderBy('created_at', 'asc');
break;
}
return $query;
}
}

@ -231,9 +231,10 @@ class User extends BaseModel implements
* Check if this user has a table with the givenname
*
* @param string $name
* @return bool
*/
public function hasTable(string $name)
{
$this->tables()->where('name', $name)->exists();
return $this->tables()->where('name', $name)->exists();
}
}

@ -4,6 +4,7 @@
namespace App\Tables;
use App\Models\Row;
use App\Models\Table;
/**
@ -127,10 +128,17 @@ abstract class BaseExporter
$count = $revision->rows()->count();
$start = 0;
while ($start < $count) {
$rq = $revision->rowsData($this->columns, false);
$columns = $this->columns;
// TODO raw query to allow selecting aggregates, column subsets, etc
$rows = $revision->rowsData($this->columns, false)
->offset($start)->limit($chunkSize)->get()->toArray();
if (count($columns)) $rq = $rq->sortByCol($columns[0]);
Row::disableCasts();
while ($start < $count) {
$rows = $rq->offset($start)->limit($chunkSize)->get()->toArray();
foreach ($rows as $row) {
unset($row['_row_pivot']);
@ -139,6 +147,7 @@ abstract class BaseExporter
$start += $chunkSize;
}
Row::enableCasts();
}
/**

@ -3,7 +3,9 @@
namespace App\Tables;
/**
* Sequential ID assigner
*/
abstract class BaseNumerator
{
/** @var int */
@ -39,10 +41,18 @@ abstract class BaseNumerator
throw new \OutOfBoundsException("Column numerator has run out of allocated GCID slots");
$key = $this->getKey($this->next);
$this->next++;
$this->advance();
return $key;
}
/**
* Advance to the next ID
*/
protected function advance()
{
$this->next++;
}
/**
* Convert numeric index to a key
*

@ -6,7 +6,6 @@ namespace App\Tables;
use App\Models\Revision;
use App\Models\Row;
use App\Models\Table;
use Illuminate\Pagination\Paginator;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use MightyPork\Exceptions\NotApplicableException;
@ -105,6 +104,20 @@ class Changeset
*/
public $removedColumns = [];
/**
* Draft row numerator state holder; numbers grow to negative,
* and are replaced with real unique row IDs when the proposal is submitted.
*
* @var int
*/
public $nextRowID = -1;
/**
* Generator iterating all properties, used internally for serialization to array
*
* @return \Generator
* @throws \ReflectionException
*/
private function walkProps()
{
$properties = (new ReflectionClass($this))->getProperties();
@ -123,16 +136,17 @@ class Changeset
* Note that the fromProposal() method should be used when the
* proposal is available, as it populates additional fields.
*
* @param \stdClass $changes
* @param \stdClass $object
* @return Changeset
*/
public static function fromObject($changes)
public static function fromObject($object)
{
$object = (array)$object;
$changeset = new Changeset();
foreach ($changeset->walkProps() as $prop) {
if (isset($changes->$prop)) {
$changeset->$prop = $changes->$prop;
if (isset($object[$prop])) {
$changeset->$prop = $object[$prop];
}
}
@ -155,22 +169,6 @@ class Changeset
return $object;
}
/**
* Check if there is any change in this changeset
*
* @return bool - any found
*/
public function hasAnyChanges()
{
foreach ($this->walkProps() as $prop) {
if (!empty($this->$prop)) {
return true;
}
}
return false;
}
/**
* Decorate / transform a single row for the editor view
*
@ -180,7 +178,7 @@ class Changeset
*/
public function transformRow($row, $decorate)
{
if ($row instanceof Row) $row = (object)$row->getAttributes();
if ($row instanceof Row) $row = (object)$row->getAttributes(); // row must be in the rowData() format
if ($decorate) {
$row->_remove = false;
@ -188,6 +186,16 @@ class Changeset
$row->_orig = [];
}
if ($decorate) {
$row->_orig = array_diff((array)$row, []);
// remove junk
unset($row->_orig['_id']);
unset($row->_orig['_new']);
unset($row->_orig['_remove']);
unset($row->_orig['_changed']);
unset($row->_orig['_orig']);
}
if ($this->isNewRow($row->_id)) {
if ($decorate) {
$row->_new = true;
@ -205,14 +213,19 @@ class Changeset
}
// if marked for removal, hide changes
if (!$row->_remove) {
if (!isset($row->_remove) || !$row->_remove) {
// Changed values
if (isset($this->rowUpdates[$row->_id])) {
$newVals = $this->rowUpdates[$row->_id];
if ($decorate) {
$row->_changed = array_keys($newVals);
$row->_orig = array_only((array)$row, $row->_changed);
$row->_orig = array_diff((array)$row, []);
unset($row->_orig['_id']);
unset($row->_orig['_new']);
unset($row->_orig['_remove']);
unset($row->_orig['_changed']);
unset($row->_orig['_orig']);
}
$row = (object)array_merge((array)$row, $newVals);
@ -223,11 +236,23 @@ class Changeset
if (!$decorate) {
foreach ($this->removedColumns as $colId) {
unset($row->$colId);
unset($row->_orig[$colId]);
}
}
// move junk left over from the select
unset($row->_row_pivot);
if ($decorate) {
$row->_loadvals = array_diff((array)$row, []);
// remove junk
unset($row->_loadvals['_id']);
unset($row->_loadvals['_new']);
unset($row->_loadvals['_remove']);
unset($row->_loadvals['_changed']);
unset($row->_loadvals['_orig']);
}
return $row;
}
@ -265,8 +290,10 @@ class Changeset
$colsById = collect($columns)->keyBy('id')->all();
$newOrder = [];
foreach ($this->columnOrder as $id) {
if (isset($colsById[$id])) {
$newOrder[] = $colsById[$id];
}
}
$leftover_keys = array_diff(array_keys($colsById), $this->columnOrder);
foreach ($leftover_keys as $id) {
@ -276,6 +303,50 @@ class Changeset
return $cachedColumns = $newOrder;
}
/**
* Retrieve a column by ID (even new columns)
*
* @param string $id
* @return Column
*/
public function fetchColumn(string $id)
{
if ($this->isNewColumn($id)) {
$c = new Column($this->newColumns[$id]);
$c->markAsNew();
return $c;
} else {
$columns = collect($this->revision->columns)->keyBy('id');
return new Column($columns[$id]);
}
}
/**
* Retrieve a column and modify it by the Changeset.
*
* @param string $id - column ID
* @return Column
*/
public function fetchAndTransformColumn(string $id)
{
$column = $this->fetchColumn($id);
if (isset($this->columnUpdates[$column->id])) {
$column->modifyByChangeset($this->columnUpdates[$column->id]);
}
if (in_array($column->id, $this->removedColumns)) {
$column->markForRemoval();
}
return $column;
}
/**
* Mark a row for removal
*
* @param int $id - row ID (_id)
*/
public function rowRemove(int $id)
{
if ($this->isNewRow($id)) {
@ -286,16 +357,44 @@ class Changeset
}
}
/**
* Undo row removal
*
* @param int $id - row ID (_id)
*/
public function rowRestore(int $id)
{
$this->removedRows = array_diff($this->removedRows, [$id]);
}
/**
* Test if a row is new
*
* @param int $id - row ID (_id)
* @return bool - is new
*/
public function isNewRow(int $id)
{
return isset($this->newRows[$id]);
}
/**
* Test if a column is new
*
* @param string $id - column ID
* @return bool - is new
*/
public function isNewColumn(string $id)
{
return isset($this->newColumns[$id]);
}
/**
* Retrieve a single row and transform it by the Changeset
*
* @param int $id - row ID (_id)
* @return \DecoratedRow|object
*/
public function fetchAndTransformRow(int $id)
{
$r = $this->fetchRow($id);
@ -303,7 +402,12 @@ class Changeset
return $transformed;
}
public function fetchColumns()
/**
* Fetch columns from DB (not including new columns)
*
* @return Column[]
*/
public function fetchRevisionColumns()
{
return Column::columnsFromJson($this->revision->columns);
}
@ -317,10 +421,12 @@ class Changeset
public function fetchRow(int $id)
{
if ($this->isNewRow($id)) {
return (object)$this->newRows[$id];
$nr = (object)$this->newRows[$id];
$nr->_new = true;
return $nr;
}
$r = $this->revision->rowsData($this->fetchColumns(), true, false)
$r = $this->revision->rowsData($this->fetchRevisionColumns(), true, false)
->whereRaw("data->>'_id' = ?", $id)->first();
if (!$r) throw new NotExistException("No such row _id = $id in this revision.");
@ -329,7 +435,10 @@ class Changeset
unset($r->pivot_revision_id);
unset($r->pivot_row_id);
return (object)$r->getAttributes();
Row::disableCasts();
$vals = (object)$r->getAttributes();
Row::enableCasts();
return $vals;
}
/**
@ -337,6 +446,7 @@ class Changeset
* if all differences were undone.
*
* @param array|object $newVals - values of the new row
* @return object - updated column
*/
public function rowUpdate($newVals)
{
@ -355,9 +465,10 @@ class Changeset
$col = $cols[$colId];
$value = $col->cast($value);
$origValue = $col->cast(isset($origRow->$colId) ? $origRow->$colId : null);
$origValue = isset($origRow->$colId) ? $origRow->$colId : null;
$origValueCast = $col->cast($origValue);
if ($value !== $origValue) {
if ($value !== $origValueCast && !($origValue===null&&$value==="")) { // fix for null in numeric cols being forcibly populated and sticking as change
$updateObj[$colId] = $value;
}
}
@ -373,10 +484,79 @@ class Changeset
unset($this->rowUpdates[$_id]);
}
}
return $this->fetchAndTransformRow($_id);
}
/**
* Get a page of added rows for display in the editor
* Update a column specification
*
* @param object $newVals - new values for the column
* @return Column - the column, modified and decorated with _orig etc
*/
public function columnUpdate($newVals)
{
$id = $newVals->id;
$col = $this->fetchColumn($id);
$updateObj = [];
foreach ($newVals as $field => $value) {
if (starts_with($field, '_')) continue; // internals
if ($value !== $col->$field) {
$updateObj[$field] = $value;
}
}
// try creating a column with the new data
new Column(array_merge($col->toArray(), $updateObj));
if ($this->isNewColumn($id)) {
$this->newColumns[$id] = array_merge($this->newColumns[$id], $updateObj);
}
else {
if (!empty($updateObj)) {
$this->columnUpdates[$id] = $updateObj;
} else {
// remove possible old update record for this row, if nothing changes now
unset($this->columnUpdates[$id]);
}
}
return $this->fetchAndTransformColumn($id);
}
/**
* Mark a column for removal, or forget it if it's a new column
*
* @param string $id - column ID
*/
public function columnRemove(string $id)
{
if ($this->isNewColumn($id)) {
unset($this->newColumns[$id]);
// remove it from order
$this->columnOrder = array_values(array_diff($this->columnOrder, [$id]));
}
else {
$this->removedColumns[] = $id;
}
$this->clearColumnOrderIfUnchanged();
}
/**
* Restore a column previously marked for removal
*
* @param string $id - column ID
*/
public function columnRestore(string $id)
{
$this->removedColumns = array_diff($this->removedColumns, [$id]);
}
/**
* Get a page of new rows, for display in the editor
*
* @param int $perPage
* @return \Illuminate\Pagination\LengthAwarePaginator|Collection|array
@ -387,15 +567,19 @@ class Changeset
}
/**
* @param Column[] $columns
* @param array $csvArray
* @param bool $forTableInsert
* @return \array[][]|Collection
* Convert a raw CSV array to a rows array, validating and casting the data.
* All created rows are assigned globally unique IDs
*
* @param Column[] $columns - ordered array of columns to collect from all CSV rows
* @param array $csvArray - CSV parsed to 2D array
* @param bool $forTableInsert - if true, row data will be wrapped in ['data'=>...]
* so it can be inserted directly into DB
* @return Collection
*/
public static function csvToRowsArray($columns, $csvArray, $forTableInsert)
public function csvToRowsArray($columns, $csvArray, $useDraftIds)
{
/** @var Collection|array[][] $rows */
$rows = collect($csvArray)->map(function ($row) use ($columns, $forTableInsert) {
/** @var Collection $rows */
$rows = collect($csvArray)->map(function ($row) use ($columns) {
if (count($row) == 0 || count($row) == 1 && $row[0] == '') return null; // discard empty lines
if (count($row) != count($columns)) {
throw new NotApplicableException("All rows must have " . count($columns) . " fields.");
@ -410,43 +594,39 @@ class Changeset
// try to stop people inserting unstructured crap / malformed CSV
throw new NotApplicableException("Value for column {$col->name} too long.");
}
$data[$col->id] = $col->cast($val);
$data[$col->id] = $col->cast(trim($val));
}
if ($forTableInsert) {
return ['data' => $data];
} else {
return $data;
}
})->filter();
if ($useDraftIds) {
$rowNumerator = new DraftRowNumerator($this, count($csvArray));
} else {
$rowNumerator = new RowNumerator(count($csvArray));
if ($forTableInsert) {
return $rows->map(function ($row) use (&$rowNumerator) {
$row['data']['_id'] = $rowNumerator->next();
return $row;
});
}
else {
return $rows->map(function ($row) use (&$rowNumerator) {
$row['_id'] = $rowNumerator->next();
return $row;
});
}
}
/**
* Append N blank rows
*
* @param int $count
*/
public function addBlankRows(int $count)
{
$numerator = new RowNumerator($count);
$columns = $this->fetchAndTransformColumns();
$template = [];
foreach ($columns as $column) {
$template[$column->id] = $column->cast(null);
}
$numerator = new DraftRowNumerator($this, $count);
foreach ($numerator->generate() as $_id) {
$row = $template;
$row['_id'] = $_id;
@ -454,14 +634,245 @@ class Changeset
}
}
/**
* Add rows from imported CSV
*
* @param array $csvArray - CSV parsed to 2D array
*/
public function addFilledRows($csvArray)
{
$columns = collect($this->fetchAndTransformColumns())->where('toRemove', false);
/** @var Column[] $columns */
$columns = array_values($this->fetchAndTransformColumns());
$columns = $columns->values()->all();
$rows = self::csvToRowsArray($columns, $csvArray, false)
$rows = self::csvToRowsArray($columns, $csvArray, true)
->keyBy('_id');
$this->newRows = array_merge($this->newRows, $rows->all());
// using '+' to avoid renumbering
$this->newRows = $this->newRows + $rows->toArray();
}
/**
* Add a blank column (pre-filled with dummies to satisfy validation)
*
* @return array - column array
*/
public function addBlankCol()
{
$cid = (new ColumnNumerator(1))->next();
$allCols = $this->fetchAndTransformColumns();
$num = count($allCols) + 1;
$col = [
'name' => "col_{$num}",
'type' => "string",
'title' => "Column {$num}",
'id' => $cid,
'_new' => true,
];
$this->newColumns[$cid] = $col;
return $col;
}
/**
* Set a new column order
*
* @param array $order - array of column IDs
*/
public function setColOrder(array $order)
{
$allCols = $this->fetchAndTransformColumns();
$ids = collect($allCols)->pluck('id')->all();
$order = array_intersect($order, $ids);
$missing = array_diff($ids, $order);
$this->columnOrder = array_values(array_merge($order, $missing));
$this->clearColumnOrderIfUnchanged();
}
/**
* Remove added rows that are not filled (user added too many with the Add Rows form)
*/
public function removeEmptyNewRows()
{
$cols = $this->fetchRevisionColumns();
$emptyTpl = collect($cols)->keyBy('id')->map(function(Column $c) {
return $c->cast(null);
})->all();
foreach ($this->newColumns as $k => $obj) {
$cols[] = $c = new Column($obj);
$c->markAsNew();
$emptyTpl[$k] = $c->cast(null);
}
$this->newRows = array_filter($this->newRows, function ($r) use ($emptyTpl) {
foreach ($r as $k => $val) {
if ($k[0] == '_') continue;
if ($val != $emptyTpl[$k]) return true;
}
return false;
});
}
/**
* Discard the column order array if it's identical to the natural / default
* ordering. This simplifies the Changeset and reduces possible conflicts
*/
public function clearColumnOrderIfUnchanged()
{
$expected = collect($this->revision->columns)
->pluck('id')
->diff($this->removedColumns)
->merge(collect($this->newColumns)->pluck('id'))
->values()->all();
$this->columnOrder = array_values($this->columnOrder);
if ($expected == $this->columnOrder) {
$this->columnOrder = [];
}
}
/**
* Clear the custom column order
*/
public function resetColumnOrder()
{
$this->columnOrder = [];
}
/**
* Restore all columns marked for removal
*/
public function resetRemovedColumns()
{
$this->removedColumns = [];
}
/**
* Discard all added columns
*/
public function resetAddedColumns()
{
$this->columnOrder = array_values(
array_diff($this->columnOrder,
collect($this->newColumns)->pluck('id')->all()
)
);
$this->newColumns = [];
$this->clearColumnOrderIfUnchanged();
}
/**
* Discard all column changes
*/
public function resetUpdatedColumns()
{
$this->columnUpdates = [];
}
/**
* Restore all rows marked for removal
*/
public function resetRemovedRows()
{
$this->removedRows = [];
}
/**
* Discard all added rows
*/
public function resetAddedRows()
{
$this->newRows = [];
}
/**
* Discard all row changes
*/
public function resetUpdatedRows()
{
$this->rowUpdates = [];
}
/**
* Get row change counts (used for the view)
*
* @return object
*/
public function getRowChangeCounts()
{
$numChangedRows = count($this->rowUpdates);
$numNewRows = count($this->newRows);
$numRemovedRows = count($this->removedRows);
return (object) [
'changed' => $numChangedRows,
'new' => $numNewRows,
'removed' => $numRemovedRows,
'any' => $numChangedRows || $numNewRows || $numRemovedRows,
];
}
/**
* Get column change counts (used for the view)
*
* @return object
*/
public function getColumnChangeCounts()
{
$numChangedColumns = count($this->columnUpdates);
$numNewColumns = count($this->newColumns);
$numRemovedColumns = count($this->removedColumns);
$reordered = count($this->columnOrder) != 0;
return (object) [
'changed' => $numChangedColumns,
'new' => $numNewColumns,
'removed' => $numRemovedColumns,
'reordered' => $reordered,
'any' => $reordered || $numChangedColumns || $numNewColumns || $numRemovedColumns,
];
}
/**
* Check if there is any change in this changeset
*
* @return bool - any found
*/
public function hasAnyChanges()
{
$colChanges = $this->getColumnChangeCounts();
$rowChanges = $this->getRowChangeCounts();
return $colChanges->any || $rowChanges->any;
}
/**
* Replace temporary negative row IDs with real unique row IDs
*/
public function renumberRows()
{
if (count($this->newRows) == 0) return;
$rows = [];
$numerator = new RowNumerator(count($this->newRows));
foreach ($this->newRows as $row) {
if ($row['_id'] < 0) {
$id = $numerator->next();
$row['_id'] = $id;
$rows[$id] = $row;
} else {
// keep ID
$rows[$row['_id']] = $row;
}
}
$this->newRows = $rows;
}
}

@ -71,7 +71,7 @@ class Column implements JsonSerializable, Arrayable
foreach ((array)$columnObject as $key => $value) {
if ($value != $this->$key) {
$this->modified_attribs[] = $key;
$this->orig_attribs[] = $this->$key;
$this->orig_attribs[$key] = $this->$key;
$this->$key = $value;
}
}
@ -103,12 +103,12 @@ class Column implements JsonSerializable, Arrayable
public static function columnsFromJson($columns)
{
if (is_string($columns)) {
$columns = json_decode($columns);
$columns = json_decode($columns, true);
}
return array_map(function ($x) {
return new Column($x);
}, $columns);
}, array_values((array)$columns));
}
/**
@ -123,24 +123,29 @@ class Column implements JsonSerializable, Arrayable
/**
* Create from object or array
*
* @param $obj
* @param object|array $obj
*/
public function __construct($obj)
{
$b = objBag($obj);
if (!$b->has('name') || $b->name == '') throw new NotApplicableException('Missing name in column');
if (!$b->has('type')) throw new NotApplicableException('Missing type in column');
if (!$b->has('name') || $b->name == '') {
throw new SimpleValidationException('name', "Missing column name.");
}
if (!$b->has('type') || !in_array($b->type, self::colTypes)) {
throw new SimpleValidationException('type', "Missing or invalid column type.");
}
if ($b->name[0] == '_') { // global row ID
throw new NotApplicableException("Column name can't start with underscore.");
if ($b->name[0] == '_' || !preg_match(IDENTIFIER_PATTERN, $b->name)) {
throw new SimpleValidationException('name', "Column name can contain only letters, digits, and underscore, and must start with a letter.");
}
if (!in_array($b->type, self::colTypes)) {
throw new NotApplicableException("\"$b->type\" is not a valid column type.");
if (!$b->has('id') || $b->id[0] == '_') {
throw new SimpleValidationException('id', "Invalid or missing column ID");
}
$this->id = $b->get('id', null);
$this->id = $b->id;
$this->name = $b->name;
$this->type = $b->type;
$this->title = $b->title ?: $b->name;
@ -149,8 +154,20 @@ class Column implements JsonSerializable, Arrayable
/**
* @return array with keys {name, title, type}
*/
public function toArray()
public function toArray($decorate=true)
{
if ($decorate) {
return [
'id' => $this->id,
'name' => $this->name,
'title' => $this->title,
'type' => $this->type,
'_new' => $this->isNew,
'_remove' => $this->toRemove,
'_changed' => $this->modified_attribs,
'_orig' => $this->orig_attribs,
];
} else {
return [
'id' => $this->id,
'name' => $this->name,
@ -158,6 +175,7 @@ class Column implements JsonSerializable, Arrayable
'type' => $this->type,
];
}
}
/**
* Convert a value to the target type, validating it in the process
@ -169,20 +187,21 @@ class Column implements JsonSerializable, Arrayable
{
switch ($this->type) {
case 'int':
if (is_null($value)) return 0;
if (is_null($value) || $value === "") return 0;
if (is_int($value)) return $value;
if (is_bool($value)) return (int)$value;
if (is_float($value)) return round($value);
if (is_numeric($value)) return intval($value);
throw new SimpleValidationException($this->id, "Could not convert value \"$value\" to int!");
case 'float':
if (is_null($value)) return 0;
if (is_int($value) || is_float($value)) return (float)$value;
if (is_null($value) || $value === "") return 0;
if (is_int($value) || is_float($value) || is_bool($value)) return (float)$value;
if (is_numeric($value)) return floatval($value);
throw new SimpleValidationException($this->id, "Could not convert value \"$value\" to float!");
case 'bool':
if (is_null($value)) return false;
if (is_null($value) || $value === "") return false;
return Utils::parseBool($value);
case 'string':

@ -0,0 +1,22 @@
<?php
namespace App\Tables;
/**
* Utility for allocating & assigning temporary row IDs
* for rows in a changeset
*/
class DraftRowNumerator extends BaseNumerator
{
/**
* Create a numerator for the given number of rows.
*
* @param int $capacity - how many
*/
public function __construct(Changeset $changeset, $capacity)
{
parent::__construct([$changeset->nextRowID - $capacity + 1, $changeset->nextRowID]);
$changeset->nextRowID -= $capacity;
}
}

@ -5,6 +5,9 @@ namespace App\Tables;
use App\Models\Row;
/**
* Utility for allocating & assigning globally unique row IDs
*/
class RowNumerator extends BaseNumerator
{
/**

@ -38,6 +38,19 @@ class WidgetFactory
"</div>";
}
public function labeledPar($label, $text, $extraClasses='', $escape=true)
{
if (false === strpos($extraClasses, 'mb-')) $extraClasses .= ' mb-2';
return
"<div class=\"row\">".
"<label class=\"text-md-right col-form-label col-md-$this->labelCols\">".e($label)."</label>".
"<p class=\"form-control-plaintext col-md-$this->fieldCols".e($extraClasses)."\">".
($escape ? e($text) : $text) .
"</p>".
"</div>";
}
/**
* Convert the given string to a-href if it is a link.
*
@ -85,6 +98,11 @@ class WidgetFactory
return $this->baseWidget('input', $name, $label)->type('email');
}
public function file($name, $label)
{
return $this->baseWidget('input', $name, $label)->type('file');
}
public function checkbox($name, $label)
{
return (new CheckboxWidget('checkbox', $name, $label))

@ -6,6 +6,11 @@ const VALI_EMAIL = 'string|email|max:255';
const VALI_TEXT = 'string|max:4095';
const VALI_LINE = 'string|max:255';
const FEATURE_FORKS = false;
const FEATURE_FAVES = true;
const FEATURE_TABLE_COMMENTS = false;
const FEATURE_PROPOSALS = false;
// global helpers
function authed() {
return ! \Auth::guest();
@ -32,6 +37,10 @@ function faker() {
return $fac = Faker\Factory::create();
}
function dark_mode($ifDark=true, $ifLight=false) {
return isset($_COOKIE["dark_mode"]) ? $ifDark : $ifLight;
}
/**
* Recursively expand validation rules
*
@ -79,7 +88,7 @@ function vali($arr) {
*/
function old_json($name, $default) {
$old = old($name, null);
if (is_string($old)) return json_decode($old);
if (is_string($old)) return fromJSON($old);
return $default;
}
@ -95,7 +104,7 @@ function toJSON($object, $emptyObj=false) {
$object = $object->toArray();
}
return \GuzzleHttp\json_encode($object, JSON_UNESCAPED_SLASHES + JSON_UNESCAPED_UNICODE);
return \GuzzleHttp\json_encode($object);
}
function fromJSON($object, $assoc=false) {
@ -117,6 +126,11 @@ function collection_paginate($items, $per_page, $mapFn = null)
$page = Request::get('page', 1);
$pageItems = $items->forPage($page, $per_page)->values();
if (count($pageItems) == 0 && $page > 1) {
$page = 1;
Request::replace(['page' => $page]);
$pageItems = $items->forPage($page, $per_page)->values();
}
return new Illuminate\Pagination\LengthAwarePaginator(
$mapFn ? $pageItems->map($mapFn) : $pageItems,

@ -231,4 +231,17 @@ return [
],
'allow_regs' => env('ALLOW_REGS', true),
'funding' => [
'target_eur' => +env('FUNDING_TARGET_EUR', 60),
'period' => env('FUNDING_PERIOD', date('Y')),
'collected_eur' => +env('FUNDING_COLLECTED_EUR', 0),
],
'href' => [
'git' => 'https://git.ondrovo.com/MightyPork/datatable.directory',
'bugtracker' => 'https://git.ondrovo.com/MightyPork/datatable.directory/issues',
'feedback' => 'mailto:feedback@datatable.directory?subject=Feedback+for+datatable.directory',
],
];

@ -17,6 +17,7 @@ class CreateRevisionsTable extends Migration
$table->increments('id');
$table->timestamps();
$table->unsignedInteger('ancestor_id')->index()->nullable(); // parent revision
$table->unsignedInteger('proposal_id')->index()->nullable(); // parent revision
$table->unsignedInteger('row_count'); // cached nbr of rows

@ -19,7 +19,7 @@ class CreateProposalsTable extends Migration
// note that a revision may be shared by multiple tables, thus a proposal may also apply to different tables
$table->unsignedInteger('table_id')->index(); // table this proposal was written for
$table->unsignedInteger('table_id')->index()->nullable(); // table this proposal was written for
$table->unsignedInteger('revision_id')->index(); // parent revision (applying it to a different revisions may cause conflicts)
$table->unsignedInteger('author_id')->index();
@ -38,6 +38,12 @@ class CreateProposalsTable extends Migration
$table->foreign('author_id')->references('id')->on('users')
->onDelete('cascade');
});
// add FK to revisions to point to the source proposal
Schema::table('revisions', function (Blueprint $table) {
$table->foreign('proposal_id')->references('id')->on('proposals')
->onDelete('set null');
});
}
/**
@ -47,6 +53,11 @@ class CreateProposalsTable extends Migration
*/
public function down()
{
// remove the FK
Schema::table('revisions', function (Blueprint $table) {
$table->dropForeign('revisions_proposal_id_foreign');
});
Schema::dropIfExists('proposals');
}
}

@ -1,37 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateRevisionProposalPivotTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('revision_proposal_pivot', function (Blueprint $table) {
$table->unsignedInteger('proposal_id')->index();
$table->unsignedInteger('revision_id')->index();
$table->foreign('proposal_id')->references('id')->on('proposals')
->onDelete('cascade');
$table->foreign('revision_id')->references('id')->on('revisions')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('revision_proposal_pivot');
}
}

846
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -14,6 +14,12 @@
"axios": "^0.18",
"bootstrap": "^4.0.0",
"cross-env": "^5.1",
"eslint": "^5.3.0",
"eslint-config-standard": "^11.0.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^7.0.1",
"eslint-plugin-promise": "^3.8.0",
"eslint-plugin-standard": "^3.1.0",
"jquery": "^3.2",
"laravel-mix": "^2.0",
"lodash": "^4.17.10",

@ -37,23 +37,14 @@ class BladeExtensionsProvider extends ServiceProvider
return "<?= e(app()->make('\\Faker\\Generator')->$method($params)) ?>";
});
// csrf token for forms
Blade::directive('formCsrf', function () {
return '<?= csrf_field() ?>';
});
// json encode
Blade::directive('json', function ($x) {
return "<?= json_encode(($x), JSON_UNESCAPED_SLASHES) ?>";
return "<?= toJSON($x) ?>";
});
// json encode, escaped
Blade::directive('jsone', function ($x) {
if (config('app.pretty_json')) {
return "<?= e(json_encode(($x), JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?>";
} else {
return "<?= e(json_encode(($x), JSON_UNESCAPED_SLASHES)) ?>";
}
return "<?= e(toJSON($x)) ?>";
});
// selected if cond true
@ -66,10 +57,6 @@ class BladeExtensionsProvider extends ServiceProvider
return "<?= ($x) ? 'checked' : '' ?>";
});
Blade::if('admin', function () {
return \Auth::user()->isAdmin();
});
Blade::if('set', function ($x) {
return config($x) != '';
});

@ -1014,8 +1014,25 @@ class Utils
return $key;
}
public static function csvToArray(string $data)
/**
* @param \SplFileObject|string $data
* @return array
*/
public static function csvToArray($data)
{
if ($data instanceof \SplFileObject) {
$data->setFlags(\SplFileObject::SKIP_EMPTY | \SplFileObject::DROP_NEW_LINE);
$lines = [];
while (! $data->eof()) {
$line = $data->fgetcsv();
if ($line !== null) {
$lines[] = $line;
}
}
return $lines;
}
else {
return array_map('str_getcsv', explode("\n", $data));
}
}
}

@ -16,7 +16,7 @@ use MightyPork\Utils\Utils;
#region Defines
define('IDENTIFIER_PATTERN', '/^[a-z][a-z0-9_]*$/i');
define('IDENTIFIER_PATTERN', '/^[a-z_][a-z0-9_]*$/i');
define('USERNAME_PATTERN', '/^[a-z0-9_-]+$/i');
#endregion

@ -162,35 +162,45 @@
[data-icon]:before,
.fa-address-card-o:before,
.fa-arrow-left:before,
.fa-bars:before,
.fa-calendar:before,
.fa-chevron-down:before,
.fa-chevron-up:before,
.fa-code-fork:before,
.fa-comment:before,
.fa-download:before,
.fa-exclamation-triangle:before,
.fa-eye:before,
.fa-facebook-square:before,
.fa-file-excel-o:before,
.fa-floppy-o:before,
.fa-github:before,
.fa-globe:before,
.fa-google:before,
.fa-history:before,
.fa-home:before,
.fa-hourglass:before,
.fa-inbox:before,
.fa-key-modern:before,
.fa-link:before,
.fa-moon-o:before,
.fa-paper-plane-o:before,
.fa-paypal:before,
.fa-pencil:before,
.fa-plus:before,
.fa-question-circle:before,
.fa-reply:before,
.fa-sign-in:before,
.fa-sign-out:before,
.fa-spinner:before,
.fa-star:before,
.fa-star-o:before,
.fa-sun-o:before,
.fa-table:before,
.fa-th-list:before,
.fa-times:before,
.fa-trash-o:before,
.fa-undo:before,
.fa-upload:before,
.fa-user:before,
.fa-user-circle-o:before,
.fa-user-plus:before,
@ -211,40 +221,50 @@
}
.fa-address-card-o:before { content: "\f100"; }
.fa-calendar:before { content: "\f101"; }
.fa-code-fork:before { content: "\f102"; }
.fa-comment:before { content: "\f103"; }
.fa-download:before { content: "\f104"; }
.fa-exclamation-triangle:before { content: "\f105"; }
.fa-eye:before { content: "\f106"; }
.fa-facebook-square:before { content: "\f107"; }
.fa-floppy-o:before { content: "\f108"; }
.fa-github:before { content: "\f109"; }
.fa-globe:before { content: "\f10a"; }
.fa-google:before { content: "\f10b"; }
.fa-history:before { content: "\f10c"; }
.fa-home:before { content: "\f10d"; }
.fa-hourglass:before { content: "\f10e"; }
.fa-inbox:before { content: "\f10f"; }
.fa-key-modern:before { content: "\f110"; }
.fa-link:before { content: "\f111"; }
.fa-pencil:before { content: "\f112"; }
.fa-plus:before { content: "\f113"; }
.fa-question-circle:before { content: "\f114"; }
.fa-sign-in:before { content: "\f115"; }
.fa-sign-out:before { content: "\f116"; }
.fa-star:before { content: "\f117"; }
.fa-star-o:before { content: "\f118"; }
.fa-table:before { content: "\f119"; }
.fa-th-list:before { content: "\f11a"; }
.fa-times:before { content: "\f11b"; }
.fa-trash-o:before { content: "\f11c"; }
.fa-undo:before { content: "\f11d"; }
.fa-user:before { content: "\f11e"; }
.fa-user-circle-o:before { content: "\f11f"; }
.fa-user-plus:before { content: "\f120"; }
.fa-users:before { content: "\f121"; }
.fa-wrench:before { content: "\f122"; }
.fa-arrow-left:before { content: "\f101"; }
.fa-bars:before { content: "\f102"; }
.fa-calendar:before { content: "\f103"; }
.fa-chevron-down:before { content: "\f104"; }
.fa-chevron-up:before { content: "\f105"; }
.fa-code-fork:before { content: "\f106"; }
.fa-comment:before { content: "\f107"; }
.fa-download:before { content: "\f108"; }
.fa-exclamation-triangle:before { content: "\f109"; }
.fa-eye:before { content: "\f10a"; }
.fa-facebook-square:before { content: "\f10b"; }
.fa-file-excel-o:before { content: "\f10c"; }
.fa-floppy-o:before { content: "\f10d"; }
.fa-github:before { content: "\f10e"; }
.fa-globe:before { content: "\f10f"; }
.fa-google:before { content: "\f110"; }
.fa-history:before { content: "\f111"; }
.fa-home:before { content: "\f112"; }
.fa-inbox:before { content: "\f113"; }
.fa-key-modern:before { content: "\f114"; }
.fa-link:before { content: "\f115"; }
.fa-moon-o:before { content: "\f116"; }
.fa-paper-plane-o:before { content: "\f117"; }
.fa-paypal:before { content: "\f118"; }
.fa-pencil:before { content: "\f119"; }
.fa-plus:before { content: "\f11a"; }
.fa-question-circle:before { content: "\f11b"; }
.fa-reply:before { content: "\f11c"; }
.fa-sign-in:before { content: "\f11d"; }
.fa-sign-out:before { content: "\f11e"; }
.fa-spinner:before { content: "\f11f"; }
.fa-star:before { content: "\f120"; }
.fa-star-o:before { content: "\f121"; }
.fa-sun-o:before { content: "\f122"; }
.fa-table:before { content: "\f123"; }
.fa-th-list:before { content: "\f124"; }
.fa-trash-o:before { content: "\f125"; }
.fa-undo:before { content: "\f126"; }
.fa-upload:before { content: "\f127"; }
.fa-user:before { content: "\f128"; }
.fa-user-circle-o:before { content: "\f129"; }
.fa-user-plus:before { content: "\f12a"; }
.fa-users:before { content: "\f12b"; }
.fa-wrench:before { content: "\f12c"; }
</style>
<!--[if lte IE 8]><script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script><![endif]-->
@ -260,7 +280,7 @@
<body class="characters-off">
<div id="page" class="container">
<header>
<h1>fa-dtbl-1 contains 35 glyphs:</h1>
<h1>fa-dtbl-1 contains 45 glyphs:</h1>
<a onclick="toggleCharacters(); return false;" href="#">Toggle Preview Characters</a>
</header>
@ -279,6 +299,32 @@
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-arrow-left" class="fa-arrow-left"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-arrow-left" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf101;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-bars" class="fa-bars"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-bars" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf102;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-calendar" class="fa-calendar"></i></span>
@ -288,7 +334,33 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-calendar" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf101;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf103;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-chevron-down" class="fa-chevron-down"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-chevron-down" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf104;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-chevron-up" class="fa-chevron-up"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-chevron-up" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf105;" />
</div>
</div>
@ -301,7 +373,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-code-fork" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf102;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf106;" />
</div>
</div>
@ -314,7 +386,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-comment" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf103;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf107;" />
</div>
</div>
@ -327,7 +399,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-download" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf104;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf108;" />
</div>
</div>
@ -341,7 +413,7 @@
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-exclamation-triangle" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-warning" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf105;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf109;" />
</div>
</div>
@ -354,7 +426,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-eye" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf106;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10a;" />
</div>
</div>
@ -367,7 +439,20 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-facebook-square" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf107;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10b;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-file-excel-o" class="fa-file-excel-o"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-file-excel-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10c;" />
</div>
</div>
@ -381,7 +466,7 @@
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-floppy-o" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-save" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf108;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10d;" />
</div>
</div>
@ -394,7 +479,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-github" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf109;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10e;" />
</div>
</div>
@ -407,7 +492,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-globe" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10a;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10f;" />
</div>
</div>
@ -420,7 +505,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-google" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10b;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf110;" />
</div>
</div>
@ -433,7 +518,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-history" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10c;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf111;" />
</div>
</div>
@ -446,59 +531,85 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-home" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10d;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf112;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-hourglass" class="fa-hourglass"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-hourglass" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10e;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-inbox" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf113;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-inbox" class="fa-inbox"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-inbox" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf10f;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-key-modern" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf114;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-key-modern" class="fa-key-modern"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-key-modern" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf110;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-link" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf115;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-link" class="fa-link"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-moon-o" class="fa-moon-o"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-link" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf111;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-moon-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf116;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-paper-plane-o" class="fa-paper-plane-o"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-paper-plane-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf117;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-paypal" class="fa-paypal"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-paypal" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf118;" />
</div>
</div>
@ -511,7 +622,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-pencil" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf112;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf119;" />
</div>
</div>
@ -524,7 +635,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-plus" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf113;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11a;" />
</div>
</div>
@ -537,7 +648,20 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-question-circle" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf114;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11b;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-reply" class="fa-reply"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-reply" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11c;" />
</div>
</div>
@ -550,7 +674,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-sign-in" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf115;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11d;" />
</div>
</div>
@ -563,7 +687,20 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-sign-out" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf116;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11e;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-spinner" class="fa-spinner"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-spinner" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11f;" />
</div>
</div>
@ -576,7 +713,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-star" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf117;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf120;" />
</div>
</div>
@ -589,47 +726,46 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-star-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf118;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf121;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-sun-o" class="fa-sun-o"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-table" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf119;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-sun-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf122;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-table" class="fa-table"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-th-list" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11a;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-table" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf123;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-times" class="fa-times"></i></span>
<span class="step size-12"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-th-list" class="fa-th-list"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-times" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-close" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11b;" />
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-th-list" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf124;" />
</div>
</div>
@ -642,7 +778,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-trash-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11c;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf125;" />
</div>
</div>
@ -655,7 +791,20 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-undo" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11d;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf126;" />
</div>
</div>
<div class="glyph">
<div class="preview-glyphs">
<span class="step size-12"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-14"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-16"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-18"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-21"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-24"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-36"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-48"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-60"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span><span class="step size-72"><span class="letters">Pp</span><i id="fa-upload" class="fa-upload"></i></span>
</div>
<div class="preview-scale">
<span class="step">12</span><span class="step">14</span><span class="step">16</span><span class="step">18</span><span class="step">21</span><span class="step">24</span><span class="step">36</span><span class="step">48</span><span class="step">60</span><span class="step">72</span>
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-upload" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf127;" />
</div>
</div>
@ -668,7 +817,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-user" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11e;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf128;" />
</div>
</div>
@ -681,7 +830,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-user-circle-o" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf11f;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf129;" />
</div>
</div>
@ -694,7 +843,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-user-plus" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf120;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf12a;" />
</div>
</div>
@ -707,7 +856,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-users" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf121;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf12b;" />
</div>
</div>
@ -720,7 +869,7 @@
</div>
<div class="usage">
<input class="class" type="text" readonly="readonly" onClick="this.select();" value=".fa-wrench" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf122;" />
<input class="point" type="text" readonly="readonly" onClick="this.select();" value="&amp;#xf12c;" />
</div>
</div>

@ -39,37 +39,47 @@
}
.fa-address-card-o::before, .fa-vcard-o::before { content: "\f100"; }
.fa-calendar::before { content: "\f101"; }
.fa-code-fork::before { content: "\f102"; }
.fa-comment::before { content: "\f103"; }
.fa-download::before { content: "\f104"; }
.fa-exclamation-triangle::before, .fa-warning::before { content: "\f105"; }
.fa-eye::before { content: "\f106"; }
.fa-facebook-square::before { content: "\f107"; }
.fa-floppy-o::before, .fa-save::before { content: "\f108"; }
.fa-github::before { content: "\f109"; }
.fa-globe::before { content: "\f10a"; }
.fa-google::before { content: "\f10b"; }
.fa-history::before { content: "\f10c"; }
.fa-home::before { content: "\f10d"; }
.fa-hourglass::before { content: "\f10e"; }
.fa-inbox::before { content: "\f10f"; }
.fa-key-modern::before { content: "\f110"; }
.fa-link::before { content: "\f111"; }
.fa-pencil::before { content: "\f112"; }
.fa-plus::before { content: "\f113"; }
.fa-question-circle::before { content: "\f114"; }
.fa-sign-in::before { content: "\f115"; }
.fa-sign-out::before { content: "\f116"; }
.fa-star::before { content: "\f117"; }
.fa-star-o::before { content: "\f118"; }
.fa-table::before { content: "\f119"; }
.fa-th-list::before { content: "\f11a"; }
.fa-times::before, .fa-close::before { content: "\f11b"; }
.fa-trash-o::before { content: "\f11c"; }
.fa-undo::before { content: "\f11d"; }
.fa-user::before { content: "\f11e"; }
.fa-user-circle-o::before { content: "\f11f"; }
.fa-user-plus::before { content: "\f120"; }
.fa-users::before { content: "\f121"; }
.fa-wrench::before { content: "\f122"; }
.fa-arrow-left::before { content: "\f101"; }
.fa-bars::before { content: "\f102"; }
.fa-calendar::before { content: "\f103"; }
.fa-chevron-down::before { content: "\f104"; }
.fa-chevron-up::before { content: "\f105"; }
.fa-code-fork::before { content: "\f106"; }
.fa-comment::before { content: "\f107"; }
.fa-download::before { content: "\f108"; }
.fa-exclamation-triangle::before, .fa-warning::before { content: "\f109"; }
.fa-eye::before { content: "\f10a"; }
.fa-facebook-square::before { content: "\f10b"; }
.fa-file-excel-o::before { content: "\f10c"; }
.fa-floppy-o::before, .fa-save::before { content: "\f10d"; }
.fa-github::before { content: "\f10e"; }
.fa-globe::before { content: "\f10f"; }
.fa-google::before { content: "\f110"; }
.fa-history::before { content: "\f111"; }
.fa-home::before { content: "\f112"; }
.fa-inbox::before { content: "\f113"; }
.fa-key-modern::before { content: "\f114"; }
.fa-link::before { content: "\f115"; }
.fa-moon-o::before { content: "\f116"; }
.fa-paper-plane-o::before { content: "\f117"; }
.fa-paypal::before { content: "\f118"; }
.fa-pencil::before { content: "\f119"; }
.fa-plus::before { content: "\f11a"; }
.fa-question-circle::before { content: "\f11b"; }
.fa-reply::before { content: "\f11c"; }
.fa-sign-in::before { content: "\f11d"; }
.fa-sign-out::before { content: "\f11e"; }
.fa-spinner::before { content: "\f11f"; }
.fa-star::before { content: "\f120"; }
.fa-star-o::before { content: "\f121"; }
.fa-sun-o::before { content: "\f122"; }
.fa-table::before { content: "\f123"; }
.fa-th-list::before { content: "\f124"; }
.fa-trash-o::before { content: "\f125"; }
.fa-undo::before { content: "\f126"; }
.fa-upload::before { content: "\f127"; }
.fa-user::before { content: "\f128"; }
.fa-user-circle-o::before { content: "\f129"; }
.fa-user-plus::before { content: "\f12a"; }
.fa-users::before { content: "\f12b"; }
.fa-wrench::before { content: "\f12c"; }

Binary file not shown.

@ -1,11 +1,11 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<!--
2018-8-5: Created with FontForge (http://fontforge.org)
2018-8-12: Created with FontForge (http://fontforge.org)
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<metadata>
Created by FontForge 20170805 at Sun Aug 5 14:30:22 2018
Created by FontForge 20170805 at Sun Aug 12 09:25:43 2018
By ondra
The Fork Awesome font is licensed under the SIL OFL 1.1 (http://scripts.sil.org/OFL). Fork Awesome is a fork based of off Font Awesome 4.7.0 by Dave Gandy. More info on licenses at https://forkawesome.github.io
</metadata>
@ -19,10 +19,10 @@ The Fork Awesome font is licensed under the SIL OFL 1.1 (http://scripts.sil.org/
panose-1="2 0 5 3 0 0 0 0 0 0"
ascent="1536"
descent="-256"
bbox="-0.14014 -256.168 2048 1536.01"
bbox="-0.470077 -256.168 2048 1536.16"
underline-thickness="89.6"
underline-position="-179.2"
unicode-range="U+0020-F122"
unicode-range="U+0020-F12C"
/>
<missing-glyph />
<glyph glyph-name="space" unicode=" " horiz-adv-x="200"
@ -32,124 +32,157 @@ d="M1024 405c0 -87 -57 -149 -128 -149h-512c-71 0 -128 62 -128 149c0 155 38 327 1
v-64c0 -18 -14 -32 -32 -32h-576c-18 0 -32 14 -32 32v64c0 18 14 32 32 32h576c18 0 32 -14 32 -32zM1792 732v-56c0 -20 -16 -36 -36 -36h-568c-20 0 -36 16 -36 36v56c0 20 16 36 36 36h568c20 0 36 -16 36 -36zM1792 992v-64c0 -18 -14 -32 -32 -32h-576
c-18 0 -32 14 -32 32v64c0 18 14 32 32 32h576c18 0 32 -14 32 -32zM1920 32v1216c0 17 -15 32 -32 32h-1728c-17 0 -32 -15 -32 -32v-1216c0 -17 15 -32 32 -32h352v96c0 18 14 32 32 32h64c18 0 32 -14 32 -32v-96h768v96c0 18 14 32 32 32h64c18 0 32 -14 32 -32v-96h352
c17 0 32 15 32 32zM2048 1248v-1216c0 -88 -72 -160 -160 -160h-1728c-88 0 -160 72 -160 160v1216c0 88 72 160 160 160h1728c88 0 160 -72 160 -160z" />
<glyph glyph-name="calendar" unicode="&#xf101;" horiz-adv-x="1664"
<glyph glyph-name="arrow-left" unicode="&#xf101;" horiz-adv-x="1472"
d="M1472 640v-128c0 -68 -45 -128 -117 -128h-704l293 -294c24 -23 38 -56 38 -90s-14 -67 -38 -90l-75 -76c-23 -23 -56 -37 -90 -37s-67 14 -91 37l-651 652c-23 23 -37 56 -37 90s14 67 37 91l651 650c24 24 57 38 91 38s66 -14 90 -38l75 -74c24 -24 38 -57 38 -91
s-14 -67 -38 -91l-293 -293h704c72 0 117 -60 117 -128z" />
<glyph glyph-name="bars" unicode="&#xf102;"
d="M1536 192v-128c0 -35 -29 -64 -64 -64h-1408c-35 0 -64 29 -64 64v128c0 35 29 64 64 64h1408c35 0 64 -29 64 -64zM1536 704v-128c0 -35 -29 -64 -64 -64h-1408c-35 0 -64 29 -64 64v128c0 35 29 64 64 64h1408c35 0 64 -29 64 -64zM1536 1216v-128
c0 -35 -29 -64 -64 -64h-1408c-35 0 -64 29 -64 64v128c0 35 29 64 64 64h1408c35 0 64 -29 64 -64z" />
<glyph glyph-name="calendar" unicode="&#xf103;" horiz-adv-x="1664"
d="M128 -128h288v288h-288v-288zM480 -128h320v288h-320v-288zM128 224h288v320h-288v-320zM480 224h320v320h-320v-320zM128 608h288v288h-288v-288zM864 -128h320v288h-320v-288zM480 608h320v288h-320v-288zM1248 -128h288v288h-288v-288zM864 224h320v320h-320v-320z
M512 1088v288c0 17 -15 32 -32 32h-64c-17 0 -32 -15 -32 -32v-288c0 -17 15 -32 32 -32h64c17 0 32 15 32 32zM1248 224h288v320h-288v-320zM864 608h320v288h-320v-288zM1248 608h288v288h-288v-288zM1280 1088v288c0 17 -15 32 -32 32h-64c-17 0 -32 -15 -32 -32v-288
c0 -17 15 -32 32 -32h64c17 0 32 15 32 32zM1664 1152v-1280c0 -70 -58 -128 -128 -128h-1408c-70 0 -128 58 -128 128v1280c0 70 58 128 128 128h128v96c0 88 72 160 160 160h64c88 0 160 -72 160 -160v-96h384v96c0 88 72 160 160 160h64c88 0 160 -72 160 -160v-96h128
c70 0 128 -58 128 -128z" />
<glyph glyph-name="code-fork" unicode="&#xf102;" horiz-adv-x="1024"
<glyph glyph-name="chevron-down" unicode="&#xf104;" horiz-adv-x="1612"
d="M1593 728l-742 -741c-25 -25 -65 -25 -90 0l-742 741c-25 25 -25 66 0 91l166 165c25 25 65 25 90 0l531 -531l531 531c25 25 65 25 90 0l166 -165c25 -25 25 -66 0 -91z" />
<glyph glyph-name="chevron-up" unicode="&#xf105;" horiz-adv-x="1612"
d="M1593 205l-166 -165c-25 -25 -65 -25 -90 0l-531 531l-531 -531c-25 -25 -65 -25 -90 0l-166 165c-25 25 -25 66 0 91l742 741c25 25 65 25 90 0l742 -741c25 -25 25 -66 0 -91z" />
<glyph glyph-name="code-fork" unicode="&#xf106;" horiz-adv-x="1024"
d="M288 64c0 53 -43 96 -96 96s-96 -43 -96 -96s43 -96 96 -96s96 43 96 96zM288 1216c0 53 -43 96 -96 96s-96 -43 -96 -96s43 -96 96 -96s96 43 96 96zM928 1088c0 53 -43 96 -96 96s-96 -43 -96 -96s43 -96 96 -96s96 43 96 96zM1024 1088c0 -71 -39 -133 -96 -166
c-3 -361 -259 -441 -429 -495c-159 -50 -211 -74 -211 -171v-26c57 -33 96 -95 96 -166c0 -106 -86 -192 -192 -192s-192 86 -192 192c0 71 39 133 96 166v820c-57 33 -96 95 -96 166c0 106 86 192 192 192s192 -86 192 -192c0 -71 -39 -133 -96 -166v-497
c51 25 105 42 154 57c186 59 292 103 294 312c-57 33 -96 95 -96 166c0 106 86 192 192 192s192 -86 192 -192z" />
<glyph glyph-name="comment" unicode="&#xf103;" horiz-adv-x="1792"
<glyph glyph-name="comment" unicode="&#xf107;" horiz-adv-x="1792"
d="M1792 640c0 -354 -401 -640 -896 -640c-49 0 -98 3 -145 8c-131 -116 -287 -198 -460 -242c-36 -10 -75 -17 -114 -22c-22 -2 -43 14 -48 38v1c-5 25 12 40 27 58c63 71 135 131 182 298c-206 117 -338 298 -338 501c0 353 401 640 896 640s896 -286 896 -640z" />
<glyph glyph-name="download" unicode="&#xf104;" horiz-adv-x="1664"
<glyph glyph-name="download" unicode="&#xf108;" horiz-adv-x="1664"
d="M1280 192c0 35 -29 64 -64 64s-64 -29 -64 -64s29 -64 64 -64s64 29 64 64zM1536 192c0 35 -29 64 -64 64s-64 -29 -64 -64s29 -64 64 -64s64 29 64 64zM1664 416v-320c0 -53 -43 -96 -96 -96h-1472c-53 0 -96 43 -96 96v320c0 53 43 96 96 96h465l135 -136
c37 -36 85 -56 136 -56s99 20 136 56l136 136h464c53 0 96 -43 96 -96zM1339 985c10 -24 5 -52 -14 -70l-448 -448c-12 -13 -29 -19 -45 -19s-33 6 -45 19l-448 448c-19 18 -24 46 -14 70c10 23 33 39 59 39h256v448c0 35 29 64 64 64h256c35 0 64 -29 64 -64v-448h256
c26 0 49 -16 59 -39z" />
<glyph glyph-name="exclamation-triangle" unicode="&#xf105;" horiz-adv-x="1792"
<glyph glyph-name="exclamation-triangle" unicode="&#xf109;" horiz-adv-x="1792"
d="M1024 161v190c0 18 -14 33 -32 33h-192c-18 0 -32 -15 -32 -33v-190c0 -18 14 -33 32 -33h192c18 0 32 15 32 33zM1022 535l18 459c0 6 -3 14 -10 19c-6 5 -15 11 -24 11h-220c-9 0 -18 -6 -24 -11c-7 -5 -10 -15 -10 -21l17 -457c0 -13 15 -23 34 -23h185
c18 0 33 10 34 23zM1008 1469l768 -1408c22 -39 21 -87 -2 -126s-65 -63 -110 -63h-1536c-45 0 -87 24 -110 63s-24 87 -2 126l768 1408c22 41 65 67 112 67s90 -26 112 -67z" />
<glyph glyph-name="eye" unicode="&#xf106;" horiz-adv-x="1792"
<glyph glyph-name="eye" unicode="&#xf10a;" horiz-adv-x="1792"
d="M1664 576c-95 147 -225 273 -381 353c40 -68 61 -146 61 -225c0 -247 -201 -448 -448 -448s-448 201 -448 448c0 79 21 157 61 225c-156 -80 -286 -206 -381 -353c171 -264 447 -448 768 -448s597 184 768 448zM944 960c0 26 -22 48 -48 48c-167 0 -304 -137 -304 -304
c0 -26 22 -48 48 -48s48 22 48 48c0 114 94 208 208 208c26 0 48 22 48 48zM1792 576c0 -25 -8 -48 -20 -69c-184 -303 -521 -507 -876 -507s-692 205 -876 507c-12 21 -20 44 -20 69s8 48 20 69c184 302 521 507 876 507s692 -205 876 -507c12 -21 20 -44 20 -69z" />
<glyph glyph-name="facebook-square" unicode="&#xf107;"
<glyph glyph-name="facebook-square" unicode="&#xf10b;"
d="M1248 1408c159 0 288 -129 288 -288v-960c0 -159 -129 -288 -288 -288h-188v595h199l30 232h-229v148c0 67 18 112 115 112l122 1v207c-21 3 -94 9 -178 9c-177 0 -299 -108 -299 -306v-171h-200v-232h200v-595h-532c-159 0 -288 129 -288 288v960c0 159 129 288 288 288
h960z" />
<glyph glyph-name="floppy-o" unicode="&#xf108;"
<glyph glyph-name="file-excel-o" unicode="&#xf10c;"
d="M1468 1156c37 -37 68 -111 68 -164v-1152c0 -53 -43 -96 -96 -96h-1344c-53 0 -96 43 -96 96v1600c0 53 43 96 96 96h896c53 0 127 -31 164 -68zM1024 1400v-376h376c-6 17 -15 34 -22 41l-313 313c-7 7 -24 16 -41 22zM1408 -128v1024h-416c-53 0 -96 43 -96 96v416
h-768v-1536h1280zM429 106h68l194 283l-189 272h-68v107h290v-107h-76l106 -159c8 -9 13 -16 17 -23c2 -3 4 -6 5 -10h2c0 -1 9 14 21 33l103 159h-74v107h279v-107h-67l-195 -282l192 -273h68v-106h-291v106h76l-107 161c-7 10 -13 16 -17 24c-2 3 -4 6 -5 10h-2
c-3 0 -9 -15 -21 -34l-103 -161h75v-106h-281v106z" />
<glyph glyph-name="floppy-o" unicode="&#xf10d;"
d="M384 0h768v384h-768v-384zM1280 0h128v896c0 19 -17 60 -30 73l-281 281c-14 14 -53 30 -73 30v-416c0 -53 -43 -96 -96 -96h-576c-53 0 -96 43 -96 96v416h-128v-1280h128v416c0 53 43 96 96 96h832c53 0 96 -43 96 -96v-416zM896 928v320c0 17 -15 32 -32 32h-192
c-17 0 -32 -15 -32 -32v-320c0 -17 15 -32 32 -32h192c17 0 32 15 32 32zM1536 896v-928c0 -53 -43 -96 -96 -96h-1344c-53 0 -96 43 -96 96v1344c0 53 43 96 96 96h928c53 0 126 -30 164 -68l280 -280c38 -38 68 -111 68 -164z" />
<glyph glyph-name="github" unicode="&#xf109;"
<glyph glyph-name="github" unicode="&#xf10e;"
d="M768 1408c424 0 768 -344 768 -768c0 -339 -220 -627 -525 -729c-39 -7 -53 17 -53 37c0 25 1 108 1 211c0 72 -24 118 -52 142c171 19 351 84 351 379c0 84 -30 152 -79 206c8 20 34 98 -8 204c-64 20 -211 -79 -211 -79c-61 17 -127 26 -192 26s-131 -9 -192 -26
c0 0 -147 99 -211 79c-42 -106 -16 -184 -8 -204c-49 -54 -79 -122 -79 -206c0 -294 179 -360 350 -379c-22 -20 -42 -54 -49 -103c-44 -20 -156 -54 -223 64c-42 73 -118 79 -118 79c-75 1 -5 -47 -5 -47c50 -23 85 -112 85 -112c45 -137 259 -91 259 -91
c0 -64 1 -124 1 -143c0 -20 -14 -44 -53 -37c-305 102 -525 390 -525 729c0 424 344 768 768 768zM291 305c-2 -4 -8 -5 -13 -2c-6 3 -9 8 -7 12c2 3 7 4 13 2c6 -3 9 -8 7 -12zM322 271c-4 -4 -11 -2 -16 3c-5 6 -6 13 -2 16c4 4 11 2 16 -3c5 -6 6 -13 2 -16zM352 226
c-4 -3 -12 0 -17 7s-5 15 0 18c5 4 13 1 17 -6c5 -7 5 -15 0 -19zM394 184c-4 -5 -13 -4 -20 3c-7 6 -9 15 -4 19c4 5 13 4 20 -3c6 -6 8 -15 4 -19zM451 159c-2 -6 -11 -9 -19 -6c-9 2 -15 9 -13 15s11 9 19 7c9 -3 15 -10 13 -16zM514 154c0 -6 -7 -11 -16 -11
c-10 -1 -17 4 -17 11c0 6 7 11 16 11c9 1 17 -4 17 -11zM572 164c1 -6 -5 -12 -14 -14s-17 2 -18 8c-1 7 5 13 14 15c9 1 17 -3 18 -9z" />
<glyph glyph-name="globe" unicode="&#xf10a;"
<glyph glyph-name="globe" unicode="&#xf10f;"
d="M768 1404c424 0 768 -344 768 -768s-344 -768 -768 -768s-768 344 -768 768s344 768 768 768zM737 1186v0c-18 0 -40 -7 -58 -7c-27 0 -61 12 -81 0s-18 -37 -27 -55s-28 -34 -28 -54s19 -36 28 -54s2 -47 27 -54s54 36 81 54s69 32 81 54s0 36 0 54s16 39 0 55
c-5 5 -14 7 -23 7zM491 1178h-6s-44 -8 -76 -13c-136 -92 -261 -300 -278 -464c23 -12 46 -22 60 -36c27 -27 83 -27 88 -56s-24 -62 -33 -80s-31 -31 -27 -54s36 -36 54 -54s37 -22 54 -54s20 -98 27 -135c9 -47 23 -85 44 -118c27 -19 72 -44 102 -58c10 28 10 93 16 122
c7 37 13 109 27 135s19 19 28 28s18 15 28 28s17 34 26 52s30 31 26 53s-36 37 -54 55s-29 39 -55 54s-73 18 -101 25s-127 12 -129 13c-2 0 -1 -6 -7 2s-2 39 -2 57s11 34 24 66c13 18 5 10 25 24c10 9 43 -44 57 -44s-3 91 6 100c36 36 128 98 128 136s-37 36 -55 54
s-46 -30 -111 -30s76 103 85 112s23 17 27 27s0 18 0 27s12 22 8 25c-2 1 -4 1 -6 1zM1212 1096c-62 -8 -139 -10 -182 -26c-45 -17 -54 -36 -81 -54s-67 -28 -81 -54s0 -54 0 -81s-26 -68 0 -82s55 37 82 55s63 64 81 54s6 -7 0 -27s-51 -41 -52 -81s102 -73 68 -126
s-188 46 -232 17s-19 -54 -28 -81s-37 -52 -27 -81s52 -34 81 -54s81 -56 82 -58s20 -99 27 -135c14 -73 -27 -199 76 -231c31 14 78 41 106 60c13 34 24 72 35 94c22 44 71 123 80 161s0 37 0 55s7 30 0 54s-36 54 -54 81s-34 64 -54 81s-46 18 -54 27s-4 8 -4 13
s-4 7 5 14s34 8 54 0s36 -36 54 -54s25 -51 54 -54s54 36 81 54c25 17 52 56 76 55c-12 131 -98 312 -193 404z" />
<glyph glyph-name="google" unicode="&#xf10b;" horiz-adv-x="1505"
<glyph glyph-name="google" unicode="&#xf110;" horiz-adv-x="1505"
d="M768 750h725c7 -39 12 -77 12 -128c0 -438 -294 -750 -737 -750c-425 0 -768 343 -768 768s343 768 768 768c207 0 381 -76 515 -201l-209 -201c-57 55 -157 119 -306 119c-262 0 -476 -217 -476 -485s214 -485 476 -485c304 0 418 218 436 331h-436v264z" />
<glyph glyph-name="history" unicode="&#xf10c;"
<glyph glyph-name="history" unicode="&#xf111;"
d="M1536 640c0 -423 -345 -768 -768 -768c-229 0 -445 101 -591 277c-10 13 -9 32 2 43l137 138c7 6 16 9 25 9c9 -1 18 -5 23 -12c98 -127 245 -199 404 -199c282 0 512 230 512 512s-230 512 -512 512c-131 0 -255 -50 -348 -137l137 -138c19 -18 24 -46 14 -69
c-10 -24 -33 -40 -59 -40h-448c-35 0 -64 29 -64 64v448c0 26 16 49 40 59c23 10 51 5 69 -14l130 -129c141 133 332 212 529 212c423 0 768 -345 768 -768zM896 928v-448c0 -18 -14 -32 -32 -32h-320c-18 0 -32 14 -32 32v64c0 18 14 32 32 32h224v352c0 18 14 32 32 32h64
c18 0 32 -14 32 -32z" />
<glyph glyph-name="home" unicode="&#xf10d;" horiz-adv-x="1612"
<glyph glyph-name="home" unicode="&#xf112;" horiz-adv-x="1612"
d="M1382 544v-480c0 -35 -29 -64 -64 -64h-384v384h-256v-384h-384c-35 0 -64 29 -64 64v480c0 2 1 4 1 6l575 474l575 -474c1 -2 1 -4 1 -6zM1605 613l-62 -74c-5 -6 -13 -10 -21 -11h-3c-8 0 -15 2 -21 7l-692 577l-692 -577c-7 -5 -15 -8 -24 -7c-8 1 -16 5 -21 11
l-62 74c-11 13 -9 34 4 45l719 599c42 35 110 35 152 0l244 -204v195c0 18 14 32 32 32h192c18 0 32 -14 32 -32v-408l219 -182c13 -11 15 -32 4 -45z" />
<glyph glyph-name="hourglass" unicode="&#xf10e;"
d="M1504 -64c18 0 32 -14 32 -32v-128c0 -18 -14 -32 -32 -32h-1472c-18 0 -32 14 -32 32v128c0 18 14 32 32 32h1472zM130 0c19 337 294 518 478 640c-184 122 -459 303 -478 640h1276c-19 -337 -294 -518 -478 -640c184 -122 459 -303 478 -640h-1276zM1504 1536
c18 0 32 -14 32 -32v-128c0 -18 -14 -32 -32 -32h-1472c-18 0 -32 14 -32 32v128c0 18 14 32 32 32h1472z" />
<glyph glyph-name="inbox" unicode="&#xf10f;"
<glyph glyph-name="inbox" unicode="&#xf113;"
d="M1023 576h316c-2 5 -3 11 -5 16l-212 496h-708l-212 -496c-2 -5 -3 -11 -5 -16h316l95 -192h320zM1536 546v-482c0 -35 -29 -64 -64 -64h-1408c-35 0 -64 29 -64 64v482c0 36 11 89 25 123l238 552c14 33 54 59 89 59h832c35 0 75 -26 89 -59l238 -552
c14 -34 25 -87 25 -123z" />
<glyph glyph-name="key-modern" unicode="&#xf110;" horiz-adv-x="1792"
<glyph glyph-name="key-modern" unicode="&#xf114;" horiz-adv-x="1792"
d="M546 1536v0c139 1 278 -52 383 -158c142 -141 187 -343 137 -525l726 -726v-319c0 -35 -29 -64 -64 -64h-300l-45 45l135 226l-46 45l-225 -135l-45 46l134 225l-45 45l-225 -134l-46 45l135 225l-45 46l-243 -139l-186 186c-182 -50 -382 -5 -524 136
c-211 212 -209 556 4 770c107 106 246 159 385 160zM405 1290v0c-41 0 -82 -16 -113 -47c-63 -63 -63 -163 0 -226s164 -63 227 0s63 163 0 226c-31 31 -73 47 -114 47z" />
<glyph glyph-name="link" unicode="&#xf111;" horiz-adv-x="1632"
<glyph glyph-name="link" unicode="&#xf115;" horiz-adv-x="1632"
d="M1440 320c0 26 -10 50 -28 68l-208 208c-18 18 -43 28 -68 28c-29 0 -52 -11 -72 -32c33 -33 72 -61 72 -112c0 -53 -43 -96 -96 -96c-51 0 -79 39 -112 72c-21 -20 -33 -43 -33 -73c0 -25 10 -50 28 -68l206 -207c18 -18 43 -27 68 -27s50 9 68 26l147 146
c18 18 28 42 28 67zM737 1025c0 25 -10 50 -28 68l-206 207c-18 18 -43 28 -68 28s-50 -10 -68 -27l-147 -146c-18 -18 -28 -42 -28 -67c0 -26 10 -50 28 -68l208 -208c18 -18 43 -27 68 -27c29 0 52 10 72 31c-33 33 -72 61 -72 112c0 53 43 96 96 96c51 0 79 -39 112 -72
c21 20 33 43 33 73zM1632 320c0 -76 -31 -150 -85 -203l-147 -146c-54 -54 -127 -83 -203 -83c-77 0 -150 30 -204 85l-206 207c-54 54 -83 127 -83 203c0 79 32 154 88 209l-88 88c-55 -56 -129 -88 -208 -88c-76 0 -150 30 -204 84l-208 208c-55 55 -84 127 -84 204
c0 76 31 150 85 203l147 146c54 54 127 83 203 83c77 0 150 -30 204 -85l206 -207c54 -54 83 -127 83 -203c0 -79 -32 -154 -88 -209l88 -88c55 56 129 88 208 88c76 0 150 -30 204 -84l208 -208c55 -55 84 -127 84 -204z" />
<glyph glyph-name="pencil" unicode="&#xf112;" horiz-adv-x="1515"
<glyph glyph-name="moon-o" unicode="&#xf116;" horiz-adv-x="1471"
d="M1262 233c-36 -6 -73 -9 -110 -9c-371 0 -672 301 -672 672c0 127 37 251 104 357c-266 -79 -456 -323 -456 -613c0 -353 287 -640 640 -640c193 0 374 88 494 233zM1465 318c-125 -271 -399 -446 -697 -446c-423 0 -768 345 -768 768c0 415 325 752 739 767
c28 1 51 -15 61 -39c11 -25 4 -54 -15 -72c-114 -104 -177 -246 -177 -400c0 -300 244 -544 544 -544c79 0 155 17 228 51c25 11 53 6 72 -13s24 -48 13 -72z" />
<glyph glyph-name="paper-plane-o" unicode="&#xf117;" horiz-adv-x="1792"
d="M1764 1525c21 -15 31 -39 27 -64l-256 -1536c-3 -19 -15 -35 -32 -45c-9 -5 -20 -8 -31 -8c-8 0 -16 2 -24 5l-527 215l-298 -327c-12 -14 -29 -21 -47 -21c-8 0 -16 1 -23 4c-25 10 -41 34 -41 60v452l-472 193c-23 9 -38 30 -40 55c-2 24 11 47 32 59l1664 960
c21 13 48 12 68 -2zM1422 26l221 1323l-1434 -827l336 -137l863 639l-478 -797z" />
<glyph glyph-name="paypal" unicode="&#xf118;" horiz-adv-x="1519"
d="M1510 890c13 -60 10 -129 -4 -204c-65 -330 -284 -444 -565 -444h-44c-34 0 -62 -25 -68 -59l-4 -19l-55 -346l-2 -15c-7 -34 -35 -59 -69 -59h-251c-28 0 -46 23 -42 51c18 112 35 224 53 336s36 223 54 335c3 24 19 37 43 37c40 0 80 1 131 0c72 -1 155 3 236 21
c108 24 206 68 287 144c73 68 122 152 155 246c15 44 27 88 35 133c2 12 5 10 12 5c55 -41 86 -96 98 -162zM1338 1172c0 -82 -19 -160 -46 -236c-52 -151 -150 -259 -302 -315c-81 -29 -166 -41 -252 -42c-60 -1 -120 0 -180 0c-65 0 -106 -32 -118 -96
c-14 -76 -69 -430 -85 -530c-1 -7 -4 -10 -12 -10h-295c-30 0 -52 26 -48 55l232 1471c6 38 40 67 79 67h598c43 0 142 -19 209 -45c142 -55 220 -167 220 -319z" />
<glyph glyph-name="pencil" unicode="&#xf119;" horiz-adv-x="1515"
d="M363 0l91 91l-235 235l-91 -91v-107h128v-128h107zM886 928c0 13 -9 22 -22 22c-6 0 -12 -2 -17 -7l-542 -542c-5 -5 -7 -11 -7 -17c0 -13 9 -22 22 -22c6 0 12 2 17 7l542 542c5 5 7 11 7 17zM832 1120l416 -416l-832 -832h-416v416zM1515 1024c0 -34 -14 -67 -37 -90
l-166 -166l-416 416l166 165c23 24 56 38 90 38s67 -14 91 -38l235 -234c23 -24 37 -57 37 -91z" />
<glyph glyph-name="plus" unicode="&#xf113;" horiz-adv-x="1408"
<glyph glyph-name="plus" unicode="&#xf11a;" horiz-adv-x="1408"
d="M1408 800v-192c0 -53 -43 -96 -96 -96h-416v-416c0 -53 -43 -96 -96 -96h-192c-53 0 -96 43 -96 96v416h-416c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h416v416c0 53 43 96 96 96h192c53 0 96 -43 96 -96v-416h416c53 0 96 -43 96 -96z" />
<glyph glyph-name="question-circle" unicode="&#xf114;"
<glyph glyph-name="question-circle" unicode="&#xf11b;"
d="M896 160v192c0 18 -14 32 -32 32h-192c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h192c18 0 32 14 32 32zM1152 832c0 183 -192 320 -364 320c-163 0 -285 -70 -371 -213c-9 -14 -5 -32 8 -42l132 -100c5 -4 12 -6 19 -6c9 0 19 4 25 12c47 60 67 78 86 92
c17 12 50 24 86 24c64 0 123 -41 123 -85c0 -52 -27 -78 -88 -106c-71 -32 -168 -115 -168 -212v-36c0 -18 14 -32 32 -32h192c18 0 32 14 32 32c0 23 29 72 76 99c76 43 180 101 180 253zM1536 640c0 -424 -344 -768 -768 -768s-768 344 -768 768s344 768 768 768
s768 -344 768 -768z" />
<glyph glyph-name="sign-in" unicode="&#xf115;"
<glyph glyph-name="reply" unicode="&#xf11c;" horiz-adv-x="1792"
d="M1792 416c0 -140 -70 -323 -127 -451c-11 -23 -22 -55 -37 -76c-7 -10 -14 -17 -28 -17c-20 0 -32 16 -32 35c0 16 4 34 5 50c3 41 5 82 5 123c0 477 -283 560 -714 560h-224v-256c0 -35 -29 -64 -64 -64c-17 0 -33 7 -45 19l-512 512c-12 12 -19 28 -19 45s7 33 19 45
l512 512c12 12 28 19 45 19c35 0 64 -29 64 -64v-256h224c328 0 736 -58 875 -403c42 -106 53 -221 53 -333z" />
<glyph glyph-name="sign-in" unicode="&#xf11d;"
d="M1184 640c0 -17 -7 -33 -19 -45l-544 -544c-12 -12 -28 -19 -45 -19c-35 0 -64 29 -64 64v288h-448c-35 0 -64 29 -64 64v384c0 35 29 64 64 64h448v288c0 35 29 64 64 64c17 0 33 -7 45 -19l544 -544c12 -12 19 -28 19 -45zM1536 992v-704c0 -159 -129 -288 -288 -288
h-320c-17 0 -32 15 -32 32c0 28 -13 96 32 96h320c88 0 160 72 160 160v704c0 88 -72 160 -160 160h-288c-25 0 -64 -5 -64 32c0 28 -13 96 32 96h320c159 0 288 -129 288 -288z" />
<glyph glyph-name="sign-out" unicode="&#xf116;" horiz-adv-x="1568"
<glyph glyph-name="sign-out" unicode="&#xf11e;" horiz-adv-x="1568"
d="M640 96c0 -28 13 -96 -32 -96h-320c-159 0 -288 129 -288 288v704c0 159 129 288 288 288h320c17 0 32 -15 32 -32c0 -28 13 -96 -32 -96h-320c-88 0 -160 -72 -160 -160v-704c0 -88 72 -160 160 -160h288c25 0 64 5 64 -32zM1568 640c0 -17 -7 -33 -19 -45l-544 -544
c-12 -12 -28 -19 -45 -19c-35 0 -64 29 -64 64v288h-448c-35 0 -64 29 -64 64v384c0 35 29 64 64 64h448v288c0 35 29 64 64 64c17 0 33 -7 45 -19l544 -544c12 -12 19 -28 19 -45z" />
<glyph glyph-name="star" unicode="&#xf117;" horiz-adv-x="1664"
<glyph glyph-name="spinner" unicode="&#xf11f;" horiz-adv-x="1664"
d="M462 142c0 -70 -57 -128 -128 -128c-70 0 -128 58 -128 128c0 71 58 128 128 128c71 0 128 -57 128 -128zM960 -64c0 -71 -57 -128 -128 -128s-128 57 -128 128s57 128 128 128s128 -57 128 -128zM256 640c0 -71 -57 -128 -128 -128s-128 57 -128 128s57 128 128 128
s128 -57 128 -128zM1458 142c0 -70 -58 -128 -128 -128c-71 0 -128 58 -128 128c0 71 57 128 128 128c70 0 128 -57 128 -128zM494 1138c0 -88 -72 -160 -160 -160s-160 72 -160 160s72 160 160 160s160 -72 160 -160zM1664 640c0 -71 -57 -128 -128 -128s-128 57 -128 128
s57 128 128 128s128 -57 128 -128zM1024 1344c0 -106 -86 -192 -192 -192s-192 86 -192 192s86 192 192 192s192 -86 192 -192zM1554 1138c0 -124 -101 -224 -224 -224c-124 0 -224 100 -224 224c0 123 100 224 224 224c123 0 224 -101 224 -224z" />
<glyph glyph-name="star" unicode="&#xf120;" horiz-adv-x="1664"
d="M1664 889c0 -18 -13 -35 -26 -48l-363 -354l86 -500c1 -7 1 -13 1 -20c0 -26 -12 -50 -41 -50c-14 0 -28 5 -40 12l-449 236l-449 -236c-13 -7 -26 -12 -40 -12c-29 0 -42 24 -42 50c0 7 1 13 2 20l86 500l-364 354c-12 13 -25 30 -25 48c0 30 31 42 56 46l502 73
l225 455c9 19 26 41 49 41s40 -22 49 -41l225 -455l502 -73c24 -4 56 -16 56 -46z" />
<glyph glyph-name="star-o" unicode="&#xf118;" horiz-adv-x="1664"
<glyph glyph-name="star-o" unicode="&#xf121;" horiz-adv-x="1664"
d="M1137 532l306 297l-422 62l-189 382l-189 -382l-422 -62l306 -297l-73 -421l378 199l377 -199zM1664 889c0 -18 -13 -35 -26 -48l-363 -354l86 -500c1 -7 1 -13 1 -20c0 -27 -12 -50 -41 -50c-14 0 -28 5 -40 12l-449 236l-449 -236c-13 -7 -26 -12 -40 -12
c-29 0 -42 24 -42 50c0 7 1 13 2 20l86 500l-364 354c-12 13 -25 30 -25 48c0 30 31 42 56 46l502 73l225 455c9 19 26 41 49 41s40 -22 49 -41l225 -455l502 -73c24 -4 56 -16 56 -46z" />
<glyph glyph-name="table" unicode="&#xf119;" horiz-adv-x="1664"
<glyph glyph-name="sun-o" unicode="&#xf122;" horiz-adv-x="1707"
d="M1430 640c0 318 -258 576 -576 576s-576 -258 -576 -576s258 -576 576 -576s576 258 576 576zM1706 363c-3 -10 -11 -17 -20 -20l-292 -96v-306c0 -10 -5 -20 -13 -26c-9 -6 -19 -8 -29 -4l-292 94l-180 -248c-6 -8 -16 -13 -26 -13s-20 5 -26 13l-180 248l-292 -94
c-10 -4 -20 -2 -29 4c-8 6 -13 16 -13 26v306l-292 96c-9 3 -17 10 -20 20s-2 21 4 29l180 248l-180 248c-6 9 -7 19 -4 29s11 17 20 20l292 96v306c0 10 5 20 13 26c9 6 19 8 29 4l292 -94l180 248c12 16 40 16 52 0l180 -248l292 94c10 4 20 2 29 -4c8 -6 13 -16 13 -26
v-306l292 -96c9 -3 17 -10 20 -20s2 -20 -4 -29l-180 -248l180 -248c6 -8 7 -19 4 -29z" />
<glyph glyph-name="table" unicode="&#xf123;" horiz-adv-x="1664"
d="M512 160v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM512 544v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1024 160v192c0 18 -14 32 -32 32h-320
c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM512 928v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1024 544v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192
c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1536 160v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1024 928v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32z
M1536 544v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1536 928v192c0 18 -14 32 -32 32h-320c-18 0 -32 -14 -32 -32v-192c0 -18 14 -32 32 -32h320c18 0 32 14 32 32zM1664 1248v-1088c0 -88 -72 -160 -160 -160
h-1344c-88 0 -160 72 -160 160v1088c0 88 72 160 160 160h1344c88 0 160 -72 160 -160z" />
<glyph glyph-name="th-list" unicode="&#xf11a;" horiz-adv-x="1792"
<glyph glyph-name="th-list" unicode="&#xf124;" horiz-adv-x="1792"
d="M512 288v-192c0 -53 -43 -96 -96 -96h-320c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h320c53 0 96 -43 96 -96zM512 800v-192c0 -53 -43 -96 -96 -96h-320c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h320c53 0 96 -43 96 -96zM1792 288v-192c0 -53 -43 -96 -96 -96h-960
c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h960c53 0 96 -43 96 -96zM512 1312v-192c0 -53 -43 -96 -96 -96h-320c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h320c53 0 96 -43 96 -96zM1792 800v-192c0 -53 -43 -96 -96 -96h-960c-53 0 -96 43 -96 96v192c0 53 43 96 96 96
h960c53 0 96 -43 96 -96zM1792 1312v-192c0 -53 -43 -96 -96 -96h-960c-53 0 -96 43 -96 96v192c0 53 43 96 96 96h960c53 0 96 -43 96 -96z" />
<glyph glyph-name="times" unicode="&#xf11b;" horiz-adv-x="1188"
d="M1188 214c0 -25 -10 -50 -28 -68l-136 -136c-18 -18 -43 -28 -68 -28s-50 10 -68 28l-294 294l-294 -294c-18 -18 -43 -28 -68 -28s-50 10 -68 28l-136 136c-18 18 -28 43 -28 68s10 50 28 68l294 294l-294 294c-18 18 -28 43 -28 68s10 50 28 68l136 136
c18 18 43 28 68 28s50 -10 68 -28l294 -294l294 294c18 18 43 28 68 28s50 -10 68 -28l136 -136c18 -18 28 -43 28 -68s-10 -50 -28 -68l-294 -294l294 -294c18 -18 28 -43 28 -68z" />
<glyph glyph-name="trash-o" unicode="&#xf11c;" horiz-adv-x="1408"
<glyph glyph-name="trash-o" unicode="&#xf125;" horiz-adv-x="1408"
d="M512 800v-576c0 -18 -14 -32 -32 -32h-64c-18 0 -32 14 -32 32v576c0 18 14 32 32 32h64c18 0 32 -14 32 -32zM768 800v-576c0 -18 -14 -32 -32 -32h-64c-18 0 -32 14 -32 32v576c0 18 14 32 32 32h64c18 0 32 -14 32 -32zM1024 800v-576c0 -18 -14 -32 -32 -32h-64
c-18 0 -32 14 -32 32v576c0 18 14 32 32 32h64c18 0 32 -14 32 -32zM1152 76v948h-896v-948c0 -48 27 -76 32 -76h832c5 0 32 28 32 76zM480 1152h448l-48 117c-3 4 -12 10 -17 11h-317c-6 -1 -14 -7 -17 -11zM1408 1120v-64c0 -18 -14 -32 -32 -32h-96v-948
c0 -110 -72 -204 -160 -204h-832c-88 0 -160 90 -160 200v952h-96c-18 0 -32 14 -32 32v64c0 18 14 32 32 32h309l70 167c20 49 80 89 133 89h320c53 0 113 -40 133 -89l70 -167h309c18 0 32 -14 32 -32z" />
<glyph glyph-name="undo" unicode="&#xf11d;"
<glyph glyph-name="undo" unicode="&#xf126;"
d="M1536 640c0 -423 -345 -768 -768 -768c-229 0 -445 101 -591 277c-10 13 -9 32 2 43l137 138c7 6 16 9 25 9c9 -1 18 -5 23 -12c98 -127 245 -199 404 -199c282 0 512 230 512 512s-230 512 -512 512c-131 0 -255 -50 -348 -137l137 -138c19 -18 24 -46 14 -69
c-10 -24 -33 -40 -59 -40h-448c-35 0 -64 29 -64 64v448c0 26 16 49 40 59c23 10 51 5 69 -14l130 -129c141 133 332 212 529 212c423 0 768 -345 768 -768z" />
<glyph glyph-name="user" unicode="&#xf11e;" horiz-adv-x="1280"
<glyph glyph-name="upload" unicode="&#xf127;" horiz-adv-x="1664"
d="M1280 64c0 35 -29 64 -64 64s-64 -29 -64 -64s29 -64 64 -64s64 29 64 64zM1536 64c0 35 -29 64 -64 64s-64 -29 -64 -64s29 -64 64 -64s64 29 64 64zM1664 288v-320c0 -53 -43 -96 -96 -96h-1472c-53 0 -96 43 -96 96v320c0 53 43 96 96 96h427c27 -74 98 -128 181 -128
h256c83 0 154 54 181 128h427c53 0 96 -43 96 -96zM1339 936c-10 -24 -33 -40 -59 -40h-256v-448c0 -35 -29 -64 -64 -64h-256c-35 0 -64 29 -64 64v448h-256c-26 0 -49 16 -59 40c-10 23 -5 51 14 69l448 448c12 13 29 19 45 19s33 -6 45 -19l448 -448
c19 -18 24 -46 14 -69z" />
<glyph glyph-name="user" unicode="&#xf128;" horiz-adv-x="1280"
d="M1280 137c0 -146 -96 -265 -213 -265h-854c-117 0 -213 119 -213 265c0 263 65 567 327 567c81 -79 191 -128 313 -128s232 49 313 128c262 0 327 -304 327 -567zM1024 1024c0 -212 -172 -384 -384 -384s-384 172 -384 384s172 384 384 384s384 -172 384 -384z" />
<glyph glyph-name="user-circle-o" unicode="&#xf11f;" horiz-adv-x="1792"
<glyph glyph-name="user-circle-o" unicode="&#xf129;" horiz-adv-x="1792"
d="M896 1536c495 0 896 -401 896 -896c0 -492 -399 -896 -896 -896c-496 0 -896 403 -896 896c0 495 401 896 896 896zM1515 185c93 128 149 285 149 455c0 423 -345 768 -768 768s-768 -345 -768 -768c0 -170 56 -327 149 -455c36 179 123 327 306 327
c81 -79 191 -128 313 -128s232 49 313 128c183 0 270 -148 306 -327zM1280 832c0 -212 -172 -384 -384 -384s-384 172 -384 384s172 384 384 384s384 -172 384 -384z" />
<glyph glyph-name="user-plus" unicode="&#xf120;" horiz-adv-x="2048"
<glyph glyph-name="user-plus" unicode="&#xf12a;" horiz-adv-x="2048"
d="M704 640c-212 0 -384 172 -384 384s172 384 384 384s384 -172 384 -384s-172 -384 -384 -384zM1664 512h352c17 0 32 -15 32 -32v-192c0 -17 -15 -32 -32 -32h-352v-352c0 -17 -15 -32 -32 -32h-192c-17 0 -32 15 -32 32v352h-352c-17 0 -32 15 -32 32v192
c0 17 15 32 32 32h352v352c0 17 15 32 32 32h192c17 0 32 -15 32 -32v-352zM928 288c0 -70 58 -128 128 -128h256v-238c-49 -36 -111 -50 -171 -50h-874c-160 0 -267 96 -267 259c0 226 53 573 346 573c16 0 27 -7 39 -17c98 -75 193 -122 319 -122s221 47 319 122
c12 10 23 17 39 17c85 0 160 -32 217 -96h-223c-70 0 -128 -58 -128 -128v-192z" />
<glyph glyph-name="users" unicode="&#xf121;" horiz-adv-x="1920"
<glyph glyph-name="users" unicode="&#xf12b;" horiz-adv-x="1920"
d="M593 640c-104 -3 -198 -48 -265 -128h-134c-100 0 -194 48 -194 159c0 81 -3 353 124 353c21 0 125 -85 260 -85c46 0 90 8 133 23c-3 -22 -5 -44 -5 -66c0 -91 29 -181 81 -256zM1664 3c0 -162 -107 -259 -267 -259h-874c-160 0 -267 97 -267 259c0 226 53 573 346 573
c34 0 158 -139 358 -139s324 139 358 139c293 0 346 -347 346 -573zM640 1280c0 -141 -115 -256 -256 -256s-256 115 -256 256s115 256 256 256s256 -115 256 -256zM1344 896c0 -212 -172 -384 -384 -384s-384 172 -384 384s172 384 384 384s384 -172 384 -384zM1920 671
c0 -111 -94 -159 -194 -159h-134c-67 80 -161 125 -265 128c52 75 81 165 81 256c0 22 -2 44 -5 66c43 -15 87 -23 133 -23c135 0 239 85 260 85c127 0 124 -272 124 -353zM1792 1280c0 -141 -115 -256 -256 -256s-256 115 -256 256s115 256 256 256s256 -115 256 -256z" />
<glyph glyph-name="wrench" unicode="&#xf122;" horiz-adv-x="1641"
<glyph glyph-name="wrench" unicode="&#xf12c;" horiz-adv-x="1641"
d="M363 64c0 35 -29 64 -64 64s-64 -29 -64 -64s29 -64 64 -64s64 29 64 64zM1007 484l-682 -682c-23 -23 -56 -37 -90 -37s-67 14 -91 37l-106 108c-24 23 -38 56 -38 90s14 67 38 91l681 681c52 -131 157 -236 288 -288zM1641 919c0 -33 -12 -74 -23 -106
c-63 -178 -234 -301 -423 -301c-247 0 -448 201 -448 448s201 448 448 448c73 0 168 -22 229 -63c10 -7 16 -16 16 -28c0 -11 -7 -22 -16 -28l-293 -169v-224l193 -107c33 19 265 165 285 165s32 -15 32 -35z" />
</font>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

@ -1,4 +1,5 @@
{
"/js/app.js": "/js/app.js",
"/css/app.css": "/css/app.css"
"/css/app.css": "/css/app.css",
"/css/app-dark.css": "/css/app-dark.css"
}

@ -0,0 +1,18 @@
font_name: fa-dtbl-1
css_selector: '.fa-{{glyph}}'
preprocessor_path: ''
autowidth: true
no_hash: true
force: false
debug: false
quiet: false
copyright: >-
The Fork Awesome font is licensed under the SIL OFL 1.1
(http://scripts.sil.org/OFL). Fork Awesome is a fork based of off Font Awesome
4.7.0 by Dave Gandy. More info on licenses at https://forkawesome.github.io
font_em: 1792
font_ascent: 1536
font_descent: 256
input:
vectors: svg
fonts_path_relative_to_css: ./

@ -0,0 +1,96 @@
# used
home # My profile link
sign-out # logout menu button
sign-in # login form
user-plus # register icon
user-circle-o # profile header, menu
vcard-o # handle icon
users # public page users list
user # single user
key-modern # account setup link
github # social login
facebook-square # social login
google # social login
inbox # button for the list of proposals
th-list # nbr of table rows
comment # comments button
code-fork # fork, or number of forks
pencil # edit / propose change button
save # form save btn
link # user homepage link icon
globe # form-group for URLs
question-circle # form help bubble
calendar # user join date
table # icon in table list
star-o # table fab btn
star # table fav btn, active
history # nbr of revisions icon
eye # visit count
download # export buttons
wrench # Table options
trash-o
plus # Add column in table editor
warning # validation fail icon
undo # Used in table editor
spinner
paper-plane-o # save proposal
upload # Upload a file
# dark mode
sun-o
moon-o
# manual sort buttons
chevron-up
chevron-down
bars # sort icon
file-excel-o # CSV button
reply # reply, or back
arrow-left # back
paypal
# Unused
; sliders
;
;
; check
; trash-o
; trash
; close
;
; eye
; eye-slash
; filter
; flag
; search
;
; legal
; rss
; reply
;
; bell
; bell-o
;
; download
; cloud-upload
; share-alt
;
; sort
; sort-asc
; sort-desc
; quote-left
;
; clock-o

@ -4,6 +4,7 @@ require('./base-setup')
require('./modules/block-collapse')
require('./modules/flash-messages')
require('./modules/form-autoalias')
require('./modules/dark-mode')
require('./vue-init')
$(function () {
@ -11,4 +12,10 @@ $(function () {
$('[data-toggle="tooltip"]').tooltip({
container: 'body'
})
$(document).on('click', 'a[data-confirm]', function (e) {
if (!window.confirm(this.dataset.confirm)) {
e.preventDefault()
}
})
})

@ -1,4 +1,4 @@
window._ = require('./udash');
window._ = require('./udash')
window.Popper = require('popper.js').default
/**

@ -1,51 +1,154 @@
<!--
Complex animated column editor for the table edit page
-->
<template>
<div>
<input type="hidden" :name="name" :value="JSON.stringify(columns)">
<table class="editor-table">
<input type="hidden" :name="name" :value="JSON.stringify(columns)" v-if="newTable">
<div :class="newTable ? ['col-md-12', 'mt-3'] : []">
<table :class="[
{'table': !newTable},
{'new-table': newTable},
{'mt-3': !newTable},
'table-narrow', 'table-sm', 'table-fixed', 'td-va-middle'
]">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Title</th>
<th></th>
<th v-if="sortable"></th>
<th :style="tdWidthStyle('name')">Name</th>
<th :style="tdWidthStyle('type')">Type</th>
<th :style="tdWidthStyle('title')">Title</th>
<th style="width:2rem" v-if="!newTable"><!-- revert icon --></th>
<th>
<a href="" type="button" v-if="!newTable && orderChanged"
@click.prevent="resetOrder()"
class="text-danger no-decoration">
Reset Order
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(col, i) in columns">
<td>
<input v-model="col.name" class="form-control" type="text" style="width: 140px">
<transition-group name="col-list" tag="tbody" ref="col-list">
<tr v-for="(col, i) in columns" :key="col.id" :ref="`col${i}`"
:class="{
dragging: col._dragging,
'text-success': col._new,
remove: col._remove
}">
<td v-if="sortable">
<span class="btn-group">
<button type="button" class="btn btn-outline-secondary drag-btn"
@keyup.up="move(i, -1)"
@keyup.down="move(i, 1)"
:ref="`col${i}-sort`"
:style="{visibility: (columns.length > 1) ? 'visible' : 'hidden'}"
@mousedown="beginDrag(i, $event)">
<v-icon class="fa-bars" alt="Drag" />
</button><!--
--><button type="button" :class="['btn', 'btn-outline-secondary', {disabled: i==0}]" v-if="manualSort"
@click.prevent="move(i, -1)">
<v-icon class="fa-chevron-up" alt="Move Up" />
</button><!--
--><button type="button" :class="['btn', 'btn-outline-secondary', {disabled: i == (columns.length-1)}]" v-if="manualSort"
@click.prevent="move(i, 1)">
<v-icon class="fa-chevron-down" alt="Move Down" />
</button>
</span>
</td>
<template v-if="col._editing || newTable">
<!-- Editable cells -->
<td :style="tdWidthStyle('name')">
<input v-model="col.name"
:class="['form-control', { 'is-invalid': col._errors && col._errors['name'] }]"
:title="(col._errors && col._errors['name']) ? col._errors['name'][0] : null"
:ref="`edit-${i}-name`"
@keydown.down="verticalTab(i, 'name', 1)"
@keydown.up="verticalTab(i, 'name', -1)"
type="text">
</td>
<td>
<select v-model="col.type" class="form-control custom-select" style="width: 110px">
<td :style="tdWidthStyle('type')">
<select v-model="col.type"
:title="(col._errors && col._errors['type']) ? col._errors['type'][0] : null"
:class="[
'form-control',
'custom-select',
{ 'is-invalid': col._errors && col._errors['type'] }]">
<option v-for="t in colTypes" :value="t">{{t}}</option>
</select>
</td>
<td>
<input v-model="col.title" class="form-control" type="text" style="width: 170px">
<td :style="tdWidthStyle('title')">
<input v-model="col.title"
:title="(col._errors && col._errors['title']) ? col._errors['title'][0] : null"
:class="['form-control', { 'is-invalid': col._errors && col._errors['title'] }]"
:ref="`edit-${i}-title`"
@keydown.down="verticalTab(i, 'title', 1)"
@keydown.up="verticalTab(i, 'title', -1)"
type="text">
</td>
</template>
<template v-else>
<!-- Value fields -->
<td v-for='cell in ["name", "type", "title"]'>
<span class="text-danger strike" title="Original value" v-if="isChanged(col, cell)">{{col._orig[cell]}}</span>
<span>{{ col[cell] }}</span>
<v-icon v-if="isChanged(col, cell)"
@click="revertCell(col, cell)"
class="fa-undo text-danger pointer"
alt="Revert Change" />
</td>
</template>
<td class="text-nowrap">
<a href="" :class="['btn', 'btn-outline-secondary', {disabled: i==0}]" @click.prevent="delCol(i)">
<v-icon class="fa-trash-o" alt="Delete column" />
<td style="text-align: center;" v-if="!newTable">
<v-icon @click="discardEdit(col)"
v-if="col._editing && colNeedsSave(col)"
class="fa-undo text-danger pointer"
alt="Revert Change" />
</td>
<td class="text-nowrap"><!--
Save button
--><a href="" :class="[
'mr-1', 'btn',
col._editing && colNeedsSave(col) ? 'btn-info' : 'btn-outline-secondary',
{'disabled': col._remove}
]"
v-if="!newTable"
@click.prevent="toggleColEditing(col)">
<v-icon v-if="col._editing" class="fa-save" alt="Save" />
<v-icon v-else class="fa-pencil" alt="Edit" />
</a><!--
--><a href="" class="btn btn-outline-secondary" v-if="i === columns.length - 1"
Delete button
--><button type="button" :class="delBtnClass(col)"
@click.prevent="toggleColDelete(col)">
<v-icon v-if="col._remove" class="fa-undo" alt="Undo Remove" />
<v-icon v-else class="fa-trash-o" alt="Remove" />
</button><!--
Add button
--><button type="button" :class="['x-add-btn', 'btn', 'btn-outline-secondary']"
v-if="i === columns.length - 1"
@click.prevent="addCol()">
<v-icon class="fa-plus" alt="Add Column" />
</a>
</button>
</td>
</tr>
</tbody>
</transition-group>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "base";
table {
border-collapse: collapse;
@media screen and (min-width: 625px) {
table.new-table {
margin-left: -66px;
}
}
td, th {
@ -53,35 +156,355 @@
@include py(1);
}
td .btn {
@include mr(1);
th {
border-top: 0 none;
}
tr.dragging {
position: relative;
z-index: 1;
//background-color: $body-bg;
}
tr.dragging .btn {
background-color: $body-bg; // no see-through buttons
}
tr.dragging .drag-btn {
// fake hover
background-color: $secondary;
color: color-yiq($secondary);
box-shadow: $btn-box-shadow, 0 0 0 $btn-focus-width rgba($secondary, .5);
}
.col-list-enter-active {
transition: all .3s cubic-bezier(.2, .3, 0, 1);
}
.col-list-enter {
opacity: 0;
transform: translateY(100%);
}
.col-list-leave {
// approximate position of delete button
clip-path: circle(calc(100% + 5em) at calc(100% - 4em) 50%);
}
.col-list-leave-active .delete-btn {
animation: col-list-leave-delete-btn .3s forwards;
}
.col-list-leave-active {
transition: all .3s cubic-bezier(.4, .1, .6, .9);
}
.col-list-leave-to {
clip-path: circle(0 at calc(100% - 4em) 50%);
}
@keyframes col-list-leave-delete-btn {
0% {
transition-timing-function: cubic-bezier(.2, .4, .6, .9);
}
50% {
transition-timing-function: cubic-bezier(.5, 0, .8, .5);
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(0);
opacity: 0;
}
}
.col-list-move {
transition: transform .3s cubic-bezier(.2, .3, 0, 1);
}
</style>
<script>
import { query } from './table-editor-utils'
export default {
props: {
name: String,
initialColumns: Array,
route: { type: String, default: null },
sortable: { type: Boolean, default: true },
manualSort: { type: Boolean, default: false },
newTable: { type: Boolean, default: false },
name: { type: String, required: true },
xColumns: { type: Array, required: true },
orderChanged: { type: Boolean, default: false },
},
data: function() {
data () {
return {
columns: this.initialColumns,
newColNum: 0,
//orderChanged: this.orderChanged,
columns: this.xColumns,
colTypes: ['string', 'int', 'float', 'bool'],
debouncedSortUpdate: _.debounce(() => this.submitColOrder(), 350)
}
},
methods: {
delCol(n) {
if (n == 0) return
/** Send a query to the server */
query (data, sucfn, erfn) {
query(this.route, data, sucfn, erfn)
},
this.columns.splice(n, 1)
submitColOrder() {
let ids = this.columns.map((c) => c.id)
this.query ({
action: 'col.sort',
order: ids,
}, (resp) => {
this.orderChanged = resp.data;
})
},
colPos(col) {
for (let n = 0; n < this.columns.length; n++) {
if (this.columns[n].id == col.id) return n;
}
throw `Col ${col.id} not found in list`;
},
/** Save a change */
submitColChange (col) {
if (_.isUndefined(col)) return
let n = this.colPos(col)
this.query({
action: 'col.update',
data: col
}, (resp) => {
this.$set(this.columns, n, resp.data)
}, (er) => {
console.log("Col save error: ", er)
if (!_.isUndefined(er.errors)) {
this.$set(this.columns[n], '_errors', er.errors)
}
})
},
addCol() {
/** Add a column at the end */
addCol () {
if (this.newTable) {
this.columns.push({
id: 'dummy_' + (this.newColNum++),
name: '',
type: 'string',
title: '',
})
} else {
this.query({
action: 'col.add',
}, (resp) => {
resp.data._editing = true;
this.columns.push(resp.data)
this.initColLoadvals(resp.data)
})
}
},
/** Move a column (dir is positive or negative) */
move (pos1, dir) {
let pos2 = pos1 + dir
// clamp
if (pos2 < 0) {
pos2 = 0
} else if (pos2 >= this.columns.length) {
pos2 = this.columns.length - 1
}
let columns = this.columns
let col1 = columns.splice(pos1, 1)[0] // unwrap returned 1-element array
columns.splice(pos2, 0, col1)
this.columns = columns
// Put focus on the new field
// For some reason, it loses it when moving down
setTimeout(() => this.$refs[`col${pos2}-sort`][0].focus(), 0)
this.debouncedSortUpdate();
},
/** User started dragging a column */
beginDrag (i, evt) {
const column = this.columns[i]
column._dragging = true
this.$set(this.columns, i, column) // notify vue
let currentIndex = i
const dragMoveListener = e => {
let cursorIndex = 0
// find cursor index by going through the list and adding the li
// heights (cant use their positions because they may be animating at
// that moment)
let accumY = this.$refs['col-list'].$el.getBoundingClientRect().top
for (let i = 0; i < this.columns.length; i++) {
if (e.clientY > accumY) {
cursorIndex = i
}
accumY += $(this.$refs['col' + i]).outerHeight(true)
}
if (cursorIndex !== currentIndex) {
this.move(currentIndex, cursorIndex - currentIndex)
currentIndex = cursorIndex
}
}
const dragEndListener = e => {
column._dragging = false
this.$set(this.columns, currentIndex, column) // notify vue
$(window).off('mousemove', dragMoveListener)
$(window).off('mouseup', dragEndListener)
}
$(window).on('mousemove', dragMoveListener)
$(window).on('mouseup', dragEndListener)
},
/** Compute classes for the column's delete button */
delBtnClass(col) {
return [
'mr-1',
'btn',
'btn-outline-danger',
'delete-btn',
{'active': col._remove},
{disabled: this.newTable && this.columns.length === 1}
]
},
/** Delete / undelete a column; New columns vanish. */
toggleColDelete(col) {
if (_.isUndefined(col)) return
let n = this.colPos(col)
if (this.newTable) {
if (this.columns.length == 1) return // can't delete the last col
// hard delete
this.columns.splice(n, 1)
} else {
let remove = !col._remove
if (col._new) {
if (!confirm(`Delete new column "${col.title}"? Any row data for this column will be lost.`)) return;
}
this.query({
action: remove ? 'col.remove' : 'col.restore',
id: col.id
}, (resp) => {
// if response is null, this was a New col
// and it was discarded without a way back - hard drop
if (_.isEmpty(resp.data)) {
this.$delete(this.columns, n)
}
else {
this.$set(this.columns, n, resp.data)
}
})
}
},
/** Toggle editing state - edit or save */
toggleColEditing (col) {
if (col._remove) return false // can't edit col marked for removal
let editing = !col._editing
let n = this.colPos(col)
if (!editing) {
this.submitColChange(col)
} else {
this.initColLoadvals(col)
this.$set(this.columns[n], '_editing', true)
}
},
initColLoadvals(col) {
let n = this.colPos(col)
let origvals = {};
_.each(col, (v, k) => {
if (k[0] != '_') origvals[k] = v
})
this.$set(this.columns[n], '_loadvals', origvals)
},
/** Test if a value cell is changed */
isChanged (col, cell) {
return col._changed && col._changed.indexOf(cell) > -1
},
/** Revert a value cell */
revertCell (col, cell) {
this.submitColChange(_.merge({}, col, {[cell]: col._orig[cell]}))
},
/** compute styles for a field cell */
tdWidthStyle(cell) {
let w = 10;
if (cell == 'name') w = '14'
if (cell == 'type') w = '12'
if (cell == 'title') w = '14'
return {width: `${w}rem`};
},
resetOrder() {
this.query({
action: 'reset.col-order'
}, (resp) => {
this.columns = resp.data
this.orderChanged = false
})
},
colNeedsSave(col) {
if (!col._loadvals) return false;
for (const [key, value] of Object.entries(col._loadvals)) {
// changed if differs from orig value and also from previous value from revision
if (col[key] != value) return true
}
return false
},
discardEdit(col) {
col._editing = false;
for (const [key, value] of Object.entries(col._loadvals)) {
col[key] = value
}
},
/**
* Move to next or prev editable cell vertically
*
* @param index
* @param field
* @param dir - +1 down, -1 up
*/
verticalTab (index, field, dir) {
if (dir == -1) {
// up
for(let i = index-1; i>= 0; i--) {
if (this.columns[i]._editing || this.newTable) {
this.$refs[`edit-${i}-${field}`][0].focus()
break;
}
}
}
else {
// down
for(let i = index+1; i < this.columns.length; i++) {
if (this.columns[i]._editing || this.newTable) {
this.$refs[`edit-${i}-${field}`][0].focus()
break;
}
}
}
}
}
}

@ -0,0 +1,38 @@
import { query } from './table-editor-utils'
export default function (opts) {
let $note = $('#field-note')
let $submit = $('#submit-button')
let $emptyPrompt = $('#empty-note-prompt')
let lastText = $note.val().trim()
let handler = _.debounce(function () {
query(opts.route, {
action: 'note.set',
text: lastText
})
}, 350)
$note.on('input', () => {
let text = $note.val().trim()
if (text !== lastText) {
lastText = text
handler()
let empty = text.length === 0
if (opts.anyChanges) {
$submit.toggleClass('disabled', empty)
}
// Hide prompt text if anything is written
$emptyPrompt.toggleClass('hidden', !empty)
}
})
// prevent submit with empty note
$submit.on('click', (e) => {
if ($note.val().trim().length === 0) {
e.preventDefault()
}
})
}

@ -1,34 +1,113 @@
<!--
Row editor for the edit-rows or add-rows card of table editor
Rows and columns marked for removal have x._remove=true
New columns have col._new=true
Changed rows have r._changed = [ids of changed fields]
and r._orig = [previous values]
Rows are identified by row._id, columns by col.id
-->
<template>
<table class="table table-hover table-sm table-fixed td-va-middle">
<div>
<div class="col-md-12 d-flex flex-wrap">
<div class="mt-3">
<div v-if="hasPages" v-html="pager"></div>
</div>
<div class="flex-grow-1"></div>
<div class="d-flex flex-nowrap mt-3">
<form :action="route" method="POST" v-if="newRows" class="form-inline mr-2">
<input type="hidden" name="_token" :value="csrf">
<input type="hidden" name="action" value="rows.add">
<input type="hidden" name="redirect" :value="pageUrl">
<div class="input-group">
<input name="count" id="newrow-count" type="number"
min=1 step=1 value=1 class="form-control" style="width: 6em">
<div class="input-group-append">
<button class="btn btn-outline-success">Add</button>
</div>
</div>
</form>
<a :href="loadCsvUrl" class="btn btn-outline-success mr-2" v-if="newRows && loadCsvUrl">
<v-icon class="fa-file-excel-o fa-pr" alt="CSV"></v-icon><!--
-->From CSV
</a>
<form :action="route" method="POST" v-if="newRows" class="form-inline mr-2">
<input type="hidden" name="_token" :value="csrf">
<input type="hidden" name="action" value="row.remove-empty-new">
<input type="hidden" name="redirect" :value="pageUrl">
<button class="btn btn-outline-danger">Remove Empty</button>
</form>
<button @click="editAll()" type="button" v-if="!newRows"
class="btn btn-outline-secondary mr-2">
Edit All
</button>
<button @click="saveAllChanges()" type="button"
:class="['btn', this.dirtyRows ? 'btn-info' : 'btn-outline-secondary']">
Save Rows
</button>
</div>
</div>
<div class="col-md-12 mt-3">
<table :class="[
'table', 'table-sm', 'table-fixed', 'td-va-middle',
{'new-rows': newRows}
]">
<thead>
<tr>
<th style="width:3rem" class="border-top-0"></th>
<th style="width:3rem" class="border-top-0"></th>
<th style="width:6rem"></th>
<th v-for="col in columns" :class="colClasses(col)" :title="col.name">{{col.title}}</th>
<th style="width:2rem"><!-- revert icon --></th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :style="rowStyle(row)">
<td>
<a href="" :class="['btn','btn-outline-danger',{'active': row._remove}]"
<tr v-for="row in orderedRows" :class="row._remove ? 'remove' : ''">
<td class="text-nowrap">
<button :class="[
'mr-1', 'btn', 'btn-outline-danger',
{'active': row._remove}
]"
@click.prevent="toggleRowDelete(row._id)">
<v-icon :class="row._remove ? 'fa-undo' : 'fa-trash-o'"
:alt="row._remove ? 'Undo Remove' : 'Remove'" />
</a>
</td>
<td>
<a href="" :class="['btn','btn-outline-secondary',{'disabled': row._remove}]"
</button><!--
--><button v-if="newRows"
:class="[
'mr-1', 'btn',
isRowChanged(row) ? 'btn-info' : 'btn-outline-secondary'
]"
@click.prevent="submitRowChange(row)">
<v-icon class="fa-save" alt="Save" />
</button><!--
--><button v-else
:class="[
'btn',
isRowChanged(row) ? 'btn-info' : 'btn-outline-secondary',
{'disabled': row._remove}
]"
@click.prevent="toggleRowEditing(row._id)">
<v-icon :class="row._editing ? 'fa-save' : 'fa-pencil'"
:alt="row._editing ? 'Save' : 'Edit'" />
</a>
<v-icon v-if="row._editing" class="fa-save" alt="Save" />
<v-icon v-else class="fa-pencil" alt="Edit" />
</button>
</td>
<template v-if="row._editing">
<template v-if="row._editing || newRows">
<td v-for="col in columns" class="pr-0">
<input v-model="row[col.id]" :class="['form-control', { 'is-invalid': row._errors && row._errors[col.id] }]"
:title="(row._errors && row._errors[col.id]) ? row._errors[col.id][0] : null"
type="text" @keyup.enter="toggleRowEditing(row._id)">
:ref="`edit-${row._id}-${col.id}`"
type="text"
@input="markRowDirty(row)"
@keydown.down="verticalTab(row._id, col.id, 1)"
@keydown.up="verticalTab(row._id, col.id, -1)"
@keyup.enter="toggleRowEditing(row._id)">
</td>
</template>
@ -43,55 +122,116 @@
</td>
</template>
<td style="text-align: center;">
<v-icon @click="discardEdit(row)"
v-if="isRowChanged(row) && (row._editing || newRows)"
class="fa-undo text-danger pointer"
alt="Revert Change" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-12 d-flex" v-if="hasPages">
<div v-if="hasPages" v-html="pager"></div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "base";
th {
border-top: 0 none;
}
table.new-rows td {
border: 0 none;
}
</style>
<script>
export default {
import { query } from './table-editor-utils'
export default {
props: {
/** True if pager should be enabled */
hasPages: Boolean,
/** Pager HTML */
pager: String,
/** Update route */
route: String,
/** Row objects */
xRows: Object, // key'd by _id
xRowOrder: Array,
/** Array of columns */
columns: Array,
lastPage: Boolean,
/** CSRF token */
csrf: String,
/** Full URL of the current page */
pageUrl: String,
/** URL to import CSV */
loadCsvUrl: String,
/** Indicate this is the Add Rows page */
newRows: {
type: Boolean,
default: false,
}
},
data: function() {
data () {
return {
/** All rows */
rows: this.xRows,
rowOrder: this.xRowOrder,
/** Number of dirty rows */
dirtyRows: 0,
}
},
methods: {
busy (yes) {
$('#draft-busy').css('opacity', yes ? 1 : 0)
},
watch: {
dirtyRows: function (value) {
console.log(`Dirty Rows ${value}`)
query (data, sucfn, erfn) {
this.busy(true)
if (!sucfn) sucfn = ()=>{}
if (!sucfn) erfn = ()=>{}
window.axios.post(this.route, data).then(sucfn).catch((error) => {
console.error(error.message)
erfn(error.response.data)
}).then(() => {
this.busy(false)
if (value > 0) {
$(window).on('beforeunload', function () {
return 'Some row changes are not saved.'
})
} else {
$(window).off('beforeunload')
}
}
},
computed: {
// JS does not respect object keys order, so we have to fix it up a little
orderedRows: function () {
if (this.newRows) return Object.values(this.rows);
let arr = []
for(let id of this.rowOrder) {
arr.push(this.rows[id])
}
return arr;
}
},
methods: {
/** Send a query to the server */
query (data, sucfn, erfn) {
query(this.route, data, sucfn, erfn)
},
toggleRowDelete(_id) {
if (!_.isDefined(this.rows[_id])) return;
/** Toggle row delete status, remove new rows (when using the editor widget for added rows) */
toggleRowDelete (_id) {
let row = this.rows[_id]
if (!_.isDefined(row)) return
let remove = !this.rows[_id]._remove
let remove = !row._remove
let wasDirty = this.isRowChanged(row)
this.query({
action: remove ? 'row.remove' : 'row.restore',
id: _id
}, (resp) => {
if (wasDirty) this.dirtyRows--
// if response is null, this was a New row
// and it was discarded without a way back - hard drop
if (_.isEmpty(resp.data)) {
@ -103,58 +243,189 @@ export default {
})
},
submitRowChange(row) {
/** Save a change */
submitRowChange (row) {
let wasDirty = this.isRowChanged(row)
this.query({
action: 'row.update',
data: row
}, (resp) => {
this.$set(this.rows, resp.data._id, resp.data);
this.$set(this.rows, row._id, resp.data)
if (wasDirty) this.dirtyRows--
}, (er) => {
if (!_.isUndefined(er.errors)) {
this.$set(this.rows[row._id], '_errors', er.errors);
this.$set(this.rows[row._id], '_errors', er.errors)
}
})
},
toggleRowEditing(_id) {
if (this.rows[_id]._remove) return false; // can't edit row marked for removal
/** Save all changed rows */
saveAllChanges () {
let toChange = _.filter(this.rows, (r) => {
let changed = this.isRowChanged(r)
if (!changed && r._editing) {
this.rows[r._id]._editing = false
}
return changed;
})
if (toChange.length === 0) return;
this.query({
action: 'row.update-many',
data: toChange
}, (resp) => {
_.each(resp.data, (row) => {
this.$set(this.rows, row._id, row)
})
this.dirtyRows = 0
}, (er) => {
// TODO - also need to improve backend to properly report which row was bad
console.log(er)
// if (!_.isUndefined(er.errors)) {
// this.$set(this.rows[row._id], '_errors', er.errors)
// }
})
},
let editing = !this.rows[_id]._editing
/** Toggle editing state - edit or save */
toggleRowEditing (_id) {
if (this.rows[_id]._remove) return false // can't edit row marked for removal
let wasEditing = this.rows[_id]._editing
if (!editing) {
if (wasEditing || this.newRows) {
this.submitRowChange(this.rows[_id])
} else {
this.$set(this.rows[_id], '_editing', true);
this.$set(this.rows[_id], '_editing', true)
}
},
colClasses(col) {
return [
'border-top-0',
{
/** Compute classes for a value cell */
colClasses (col) {
return {
'text-danger': col._remove,
'strike': col._remove,
'text-success': col._new
}
]
},
rowStyle(row) {
return {
opacity: row._remove ? .8 : 1,
backgroundColor:
row._remove ? '#FFC4CC':
'transparent'
/** Test if a value cell is changed */
isChanged (row, colId) {
return row._changed && row._changed.indexOf(colId) > -1
},
/** Test if any field in the row is changed */
isRowChanged (row, cached = false) {
if (this.newRows) {
return row._changed && row._changed.length
} else {
if (cached) return row._dirty
// check against loadvals array
for (const [key, value] of Object.entries(row._loadvals)) {
// changed if differs from orig value and also from previous value from revision
if (row[key] != value && row[key] != row._orig[key]) return true
}
return false
}
},
isChanged (row, colId) {
return row._changed && row._changed.indexOf(colId) > -1
/** Revert a value cell */
revertCell (row, colId) {
this.submitRowChange(_.merge({}, row, {[colId]: row._orig[colId]}))
},
revertCell(row, colId) {
this.submitRowChange(_.merge({}, row, { [colId]: row._orig[colId] }))
/** Mark row as dirty and needing a save */
markRowDirty (row) {
let wasDirty = this.isRowChanged(row, true)
let changes = []
_.each(this.columns, (col) => {
let val = row[col.id];
let orig = row._orig[col.id];
if (_.isUndefined(orig)) {
// null in DB
if (val !== '' && val !== 0) {
changes.push(col.id)
console.log('is dirty', JSON.stringify(val))
}
} else if (val != orig) {
changes.push(col.id)
}
})
this.rows[row._id]._changed = changes
let isDirty = this.isRowChanged(row)
if (wasDirty && !isDirty) this.dirtyRows--
else if (!wasDirty && isDirty) this.dirtyRows++
row._dirty = isDirty
},
discardEdit (row) {
let wasDirty = this.isRowChanged(row)
_.each(this.newRows ? row._orig : row._loadvals, (val, id) => {
row[id] = val
})
row._editing = false
row._dirty = false
if (this.newRows) row._changed = []
this.$set(this.rows, row._id, row)
if (wasDirty) this.dirtyRows--
},
editAll() {
_.each(this.rows, (row, id) => {
this.$set(this.rows[id], '_editing', true)
})
},
/**
* Move to next or prev editable cell vertically
*
* @param row_id
* @param col_id
* @param dir - +1 down, -1 up
*/
verticalTab (row_id, col_id, dir) {
if (dir == -1) {
// up
let last_editable_row = null
for (let row of this.orderedRows) {
if (row._id === row_id) {
break
}
if (row._editing || this.newRows) {
last_editable_row = row
}
}
if (last_editable_row) {
this.$refs[`edit-${last_editable_row._id}-${col_id}`][0].focus()
}
}
else {
// down
let first_editable_row = null
let foundself = false
for (let row of this.orderedRows) {
if (row._id === row_id) {
foundself = true
continue
}
if (foundself && (row._editing || this.newRows)) {
first_editable_row = row
break
}
}
if (first_editable_row) {
this.$refs[`edit-${first_editable_row._id}-${col_id}`][0].focus()
}
}
}
},
}
}
</script>

@ -1 +1,6 @@
@import "../../sass/bootstrap-base";
tr.remove, tr.remove:hover {
//opacity: .8;
background-color: #FFC4CC;
}

@ -0,0 +1,26 @@
function busy (yes) {
$('#draft-busy').css('opacity', yes ? 1 : 0)
}
let loaderHideTimeout;
function query (route, data, sucfn, erfn) {
if (!sucfn) sucfn = () => {}
if (!erfn) erfn = () => {}
clearTimeout(loaderHideTimeout)
busy(true)
window.axios.post(route, data)
.then(sucfn)
.catch((error) => {
console.error(error.message)
erfn(error.response.data)
})
.finally(() => {
loaderHideTimeout = setTimeout(() => busy(false), 50)
})
}
export {
busy,
query
}

@ -1,6 +1,6 @@
// Source & more langs: https://gist.github.com/sgmurphy/3095196
/* eslint-disable object-property-newline */
const char_map = {
// Latin
'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', 'Ç': 'C',
@ -27,8 +27,9 @@ const char_map = {
'Ą': 'A', 'Ć': 'C', 'Ę': 'e', 'Ł': 'L', 'Ń': 'N', 'Ś': 'S', 'Ź': 'Z',
'Ż': 'Z',
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ś': 's', 'ź': 'z',
'ż': 'z',
'ż': 'z'
}
/* eslint-enable object-property-newline */
const alnum = new RegExp('[^a-z0-9]+', 'ig')
@ -99,4 +100,4 @@ function url_slug (s, opt) {
return opt.lowercase ? s.toLowerCase() : s
}
module.exports = url_slug;
module.exports = url_slug

@ -1,11 +1,11 @@
// toggle collapse when clicked outside link, without drag
$(document)
.on('mousedown', '.block-collapse', function(e) {
.on('mousedown', '.block-collapse', function (e) {
let $bc = $(e.target).closest('.block-collapse')
$bc.data('mx', e.screenX)
$bc.data('my', e.screenY)
})
.on('mouseup', '.block-collapse', function(e) {
.on('mouseup', '.block-collapse', function (e) {
if (e.target.nodeName === 'A') return
let $bc = $(e.target).closest('.block-collapse')

@ -0,0 +1,35 @@
$(function () {
// theme switcher without reloading
let themeStyle = $('#theme-style')
const lightURL = themeStyle.data('light-url')
const darkURL = themeStyle.data('dark-url')
const navbar = $('.page-navbar')
const logo = $('#navbar-logo')
window.toggleDarkMode = function () {
let newStyle = document.createElement('link')
newStyle.rel = 'stylesheet'
if (themeStyle.attr('href') === lightURL) {
newStyle.href = darkURL
navbar.removeClass('navbar-light')
navbar.addClass('navbar-dark')
logo.attr('src', logo.data('src-dark'))
document.cookie = 'dark_mode=1'
} else {
newStyle.href = lightURL
navbar.removeClass('navbar-dark')
navbar.addClass('navbar-light')
logo.attr('src', logo.data('src-light'))
document.cookie = 'dark_mode=0;expires=' + new Date().toUTCString()
}
// remove old css after new css has loaded to prevent FOUC
let oldThemeStyle = themeStyle
themeStyle = $(newStyle)
themeStyle.on('load', () => oldThemeStyle.remove())
$(document.head).append(themeStyle)
}
})

@ -1,4 +1,4 @@
$(function() {
$(function () {
// auto hide flash alerts
let $notifs = $('div.alert').not('.alert-important').addClass('fadeout')
setTimeout(() => {

@ -5,8 +5,10 @@ export { default as isUndefined } from 'lodash/isUndefined'
export { default as merge } from 'lodash/merge'
export { default as unset } from 'lodash/unset'
export { default as isEmpty } from 'lodash/isEmpty'
export { default as debounce } from 'lodash/debounce'
export { default as filter } from 'lodash/filter'
function isDefined(x) {
return typeof(x) !== 'undefined';
function isDefined (x) {
return typeof x !== 'undefined'
}
export { isDefined }

@ -1,27 +1,23 @@
window.Vue = require('vue');
import DraftNotePage from './components/DraftNotePage'
const ColumnEditorCtor = Vue.component('column-editor', require('./components/ColumnEditor.vue'));
const RowsEditorCtor = Vue.component('rows-editor', require('./components/RowsEditor.vue'));
const IconCtor = Vue.component('v-icon', require('./components/Icon.vue'));
window.Vue = require('vue')
// const app = new Vue({
// el: '#app'
// });
const ColumnEditor = Vue.component('column-editor', require('./components/ColumnEditor.vue'))
const RowsEditor = Vue.component('rows-editor', require('./components/RowsEditor.vue'))
const Icon = Vue.component('v-icon', require('./components/Icon.vue'))
window.app = {
ColumnEditor: function(selector, data) {
new ColumnEditorCtor({
propsData: data
}).$mount(selector);
ColumnEditor (selector, data) {
return new ColumnEditor({ propsData: data }).$mount(selector)
},
RowsEditor: function(selector, data) {
new RowsEditorCtor({
propsData: data
}).$mount(selector);
RowsEditor (selector, data) {
return new RowsEditor({ propsData: data }).$mount(selector)
},
Icon (selector, data) {
return new Icon({ propsData: data }).$mount(selector)
},
Icon: function(selector, data) {
new IconCtor({
propsData: data
}).$mount(selector);
}
DraftNotePage
}

@ -37,3 +37,12 @@
}
}
.card .block-collapse::after{
background: linear-gradient(170deg, rgba($card-bg, 0) 30%, $card-bg 90%),
linear-gradient(to bottom, rgba($card-bg, 0) 80%, $card-bg 100%);
}
.infobox .block-collapse::after{
background: linear-gradient(170deg, rgba($body-bg, 0) 30%, $body-bg 90%),
linear-gradient(to bottom, rgba($body-bg, 0) 80%, $body-bg 100%);
}

@ -23,7 +23,7 @@
@import "~bootstrap/scss/badge";
//@import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert";
//@import "~bootstrap/scss/progress";
@import "~bootstrap/scss/progress";
@import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/close";

@ -34,3 +34,16 @@
.fa-huge {
font-size: 2em;
}
.fa-spin::before {
animation: fa-spin 2s infinite linear;
}
@keyframes fa-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}

@ -0,0 +1,17 @@
.funding-progress {
height: 2rem;
@extend .box-shadow;
border: 1px solid $success;
.progress-bar {
span {
display: inline-block;
white-space: nowrap;
padding: dist(1);
font-size: 1rem;
color: white;
text-shadow: 0 1px 2px black,
0 1px 2px black;
font-weight: bold;
}
}
}

@ -17,6 +17,10 @@
box-shadow: 0 2px 3px rgba(black, .3);
}
.box-shadow-thin {
box-shadow: 0 2px 3px rgba(black, .15);
}
.strike {
text-decoration: line-through;
}
@ -30,6 +34,19 @@
cursor: pointer;
}
.noscript-hide {
display: none;
.noscript-hide,
#mainNavContent.noscript-hide { // hack to circumvent bootstraps very liberal use of !important
display: none !important;
}
.bold {
font-weight: bold;
}
.italic {
font-style: italic;
}
.no-decoration {
text-decoration: none !important;
}

@ -0,0 +1,12 @@
// box e.g. above a table in the table view
.infobox {
margin-left: auto;
margin-right: auto;
@include mb(3);
@include px(0);
@include py(2);
justify-content: center;
@extend .border;
@extend .rounded;
@extend .box-shadow;
}

@ -0,0 +1,4 @@
// Variables
$theme: dark;
@import "app";

@ -5,6 +5,14 @@ html {
overflow-y: scroll;
}
dd {
margin-left: dist(4);
}
dt {
font-size: 1.2rem;
}
@import "helpers";
@import "fa-utils";
@import "block-collapse";
@ -22,9 +30,19 @@ html {
@import "bootstrap-customizations/typography";
@import "bootstrap-customizations/nav";
@import "bootstrap-customizations/table";
@import "bootstrap-customizations/notification";
@import "infobox";
@import "funding";
.bio-table {
td {
padding-top: dist(2);
}
tr:first-child {
td {
padding-top: 0;
}
}
}

@ -39,3 +39,7 @@
min-width: 1.5rem;
text-align:center;
}
.card .auth-footer {
background-color: transparent; // see through at the card bg
}

@ -2,5 +2,5 @@
.page-footer {
font-size: 95%;
background: $body-bg;
border-top: 2px dotted $gray-200;
border-top: 2px dotted $footer-separator-color;
}

@ -0,0 +1,7 @@
.nav-tabs .nav-link.highlight {
border-color: $nav-tabs-link-hover-border-color;
&:hover, &:focus {
border-color: $nav-tabs-link-hover-border-color;
}
}

@ -1,6 +1,6 @@
// layout tweaks
.page-navbar {
background: white;
background: $navbar-bg;
border-bottom: 1px solid $primary;
box-shadow: 0 -3px 8px rgba(black, 1);
@ -17,3 +17,23 @@
.dropdown-menu {
box-shadow: 0 3px 4px 0 rgba(black, .1);
}
.dropdown-menu .dropdown-item.nav-link {
cursor: pointer;
}
@if $theme == 'dark' {
.page-navbar .dark-mode-switch-on {
display: block;
}
.page-navbar .dark-mode-switch-off {
display: none;
}
} @else {
.page-navbar .dark-mode-switch-on {
display: none;
}
.page-navbar .dark-mode-switch-off {
display: block;
}
}

@ -0,0 +1,3 @@
.alert-warning {
border-color: #d8c99a;
}

@ -7,3 +7,31 @@
border: 0 none !important;
}
}
@media (min-width: 769px) and (max-width: 991px) {
#table-infobox {
flex-direction: column-reverse;
}
#infobox-tabContent,
#infobox-tab-container {
max-width: 100%;
}
#infobox-tab {
flex-direction: row !important;
}
}
@media (max-width: 991px) {
#table-infobox {
border-left: none !important;
}
#table-title-container {
flex-wrap: wrap;
justify-content: space-between;
}
#table-id {
width: 100%;
}
#table-title {
margin-left: 0 !important;
}
}

@ -7,3 +7,7 @@
vertical-align: middle !important;
}
}
.table.table-narrow {
width: auto;
}

@ -25,3 +25,60 @@ $tooltip-arrow-color: $tooltip-bg;
$pagination-padding-y-sm: dist(1);
$pagination-padding-x-sm: dist(3);
$navbar-bg: white;
$footer-separator-color: $gray-200;
$theme: light !default;
@if $theme == dark {
// dark mode overrides
$body-bg: $gray-900;
$body-color: $gray-100;
$text-muted: $gray-500;
$link-color: lighten($primary, 15%);
$link-hover-color: lighten($primary, 20%);
$card-bg: $gray-800;
$list-group-bg: $gray-800;
$list-group-hover-bg: $gray-700;
$list-group-disabled-color: $gray-400;
$list-group-action-color: $gray-200;
$list-group-action-hover-color: $gray-200;
$list-group-action-active-bg: $gray-600;
$list-group-action-active-color: $gray-100;
$tooltip-color: rgba($white, 0.9);
$navbar-bg: lighten($gray-900, 2%);
$footer-separator-color: $gray-800;
$close-color: $white;
$input-bg: $gray-700;
$input-color: $gray-200;
$input-disabled-bg: $gray-900;
$input-border-color: $gray-600;
$input-focus-bg: $input-bg;
$input-focus-color: $input-color;
$input-group-addon-bg: $gray-800;
$input-group-addon-color: $input-color;
$input-group-addon-border-color: $input-border-color;
$custom-select-color: $input-color;
$custom-select-bg: $input-bg;
$custom-select-disabled-bg: $input-disabled-bg;
$custom-select-indicator-color: $gray-300;
$custom-select-border-color: $input-border-color;
$custom-select-indicator: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='#{$custom-select-indicator-color}' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E"), "#", "%23");
$dropdown-bg: $gray-700;
$dropdown-link-color: $gray-300;
$dropdown-link-hover-color: lighten($gray-300, 5%);
$dropdown-link-hover-bg: $gray-800;
$dropdown-link-disabled-color: $gray-500;
$dropdown-header-color: $gray-400;
$border-color: $gray-600;
}

@ -0,0 +1,54 @@
@extends('layouts.app')
@php
$target = config('app.funding.target_eur');
$collected = config('app.funding.collected_eur');
$period = config('app.funding.period');
$percent = ($collected / $target) * 100;
if ($percent == 0) $percent = 1; // make a bit of the green show up
@endphp
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1>Donations</h1>
<p>
Donations are most welcome, as this is a non-profit endeavour without ads, and keeping it
online is not free. Please use <a href="https://www.paypal.me/MightyPork">@icon(fa-paypal) PayPal.me</a> to donate.
</p>
<p>
The donation gauge below indicates how much money we would need to fully cover the hosting
and domain fees this year.
</p>
<div class="m-3">
<b>
Funding for {{$period}}, target: {{$target}}&nbsp;
</b>
<div class="progress funding-progress">
<div class="progress-bar progress-bar-striped bg-success"
role="progressbar" style="width: {{$percent}}%" aria-valuenow="{{$percent}}"
aria-valuemin="0" aria-valuemax="100">
<span>{{$collected}}&nbsp;</span>
</div>
</div>
</div>
<p>
The gauge is updated manually and PayPal is not instant either, so don't worry if it takes a bit
for your donation to show up. If you'd like to be listed as a sponsor, please write it in the
PayPal note.
</p>
<h2>Sponsors</h2>
<ul>
<li class="text-muted">Be the first!</li>
</ul>
</div>
</div>
@endsection

@ -0,0 +1,99 @@
@extends('layouts.app')
@php
$target = config('app.funding.target_eur');
$collected = config('app.funding.collected_eur');
$period = config('app.funding.period');
$percent = ($collected / $target) * 100;
if ($percent == 0) $percent = 1; // make a bit of the green show up
@endphp
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1>Frequently Asked Questions</h1>
<dl>
<dt>How to report bugs?</dt>
<dd>
You can <a href="{{ config('app.href.feedback') }}">send us an e-mail</a>, or submit your issues to
our <a href="{{ config('app.href.bugtracker') }}">bugtracker</a>.
</dd>
<dt>Can I donate?</dt>
<dd>
Donations are most welcome, please see our <a href="{{route('donate')}}">donations page</a>.
</dd>
<dt>Will there be...?</dt>
<dd>
The answer is likely yes, but keep in mind that this is a hobby project without any budget,
so things tend to go slowly. You can check the <a href="{{ config('app.href.bugtracker') }}">bugtracker</a>
for issues marked "TODO", those are planned features. You can also try to add a feature yourself, see below...
</dd>
<dt>Is datatable.directory open source?</dt>
<dd>
Yes. The upstream code is hosted at <a href="{{ config('app.href.git') }}">git.ondrovo.com/MightyPork/datatable.directory</a>,
where you can register and submit issues, comments, and pull requests.
</dd>
<dt>How can I contribute?</dt>
<dd>
If you'd like to help with development, please head over to
<a href="{{ config('app.href.git') }}">our git repository</a>.
We welcome any improvements, ideas, or bug reports.
</dd>
<dt>I deleted my table, can I restore it?</dt>
<dd>
Tables can't be restored, but there is a chance that your table data could be, if you reach the admin
quickly. If anyone made a fork of your table, you can also simply fork it back.
</dd>
<dt>I want to report something illegal / bad</dt>
<dd>
For now, please send us a <a href="{{ config('app.href.feedback') }}">feedback e-mail</a>.
A built-in reports system will be added later.
</dd>
<dt>What technologies are used?</dt>
<dd>
<p>
The server is written in <a href="http://php.net/">PHP</a> using the <a href="https://laravel.com/">Laravel</a> framework.
The data is stored in a <a href="https://www.postgresql.org/">PostgreSQL</a> database.
The front-end is based on <a href="https://getbootstrap.com/">Bootstrap 4</a>,
including <a href="https://jquery.com/">jQuery Slim</a>, <a href="https://lodash.com/">lodash</a>,
the <a href="https://github.com/axios/axios">axios</a> HTTP library,
and the reactive JavaScript framework <a href="https://vuejs.org/">Vue</a>.
We use <a href="https://webpack.js.org/">Webpack</a> to compile and minify JavaScript and <a href="https://sass-lang.com/">SCSS</a>.
Most icons come from <a href="https://forkawesome.github.io/Fork-Awesome/">Fork Awesome</a>, using our
<a href="https://git.ondrovo.com/MightyPork/fork-awesome-customizer">Fork Awesome customizer</a> to
exclude unused icons.
</p>
</dd>
<dt>How does it work?</dt>
<dd>
<p>
It's complicated, and a great deal of thought went into the database design.
</p>
<p>
A key thing to know is that table rows and revisions are immutable, and work in a way
a bit similar to Git, except we don't actually use Git, it's all in the database.
Rows and columns have unique IDs that ensure a change proposal (merge request) can be applied to any
fork of a table, and in any revision. A row change results in a new copy of the row being created,
but maintaining the original row ID. Columns are, likewise, identified by their IDs; names are defined
only in the revision object (a "header" of a table version), so changing a column name or order does not
alter the row data in any way.
</p>
</dd>
</dl>
</div>
</div>
@endsection

@ -8,13 +8,22 @@
<p>
<i>datatable.directory</i> does not collect anything beyond what users
themselves enter into the application, plus limited data from OAuth providers
to facilitate social logins. User IP addresses and other technical data may be
temporarily written to the server log for debugging purposes.
themselves enter into the application, plus limited data from OAuth2 providers
to facilitate social logins. User IP addresses and other technical info may be
written to the temporary server log.
</p>
<div class="float-right m-2 mt-1 ml-3 no-mobile" style="font-size: 5em">🍪</div>
<h2>Cookies</h2>
<p>
We use cookies to track table views, maintain a session, and store some
browser-specific settings.
</p>
<p>
We do not share any user data with third parties.
We do not share any data or metrics with third parties.
</p>
</div>
</div>

@ -8,11 +8,36 @@
<p>
<i>datatable.directory</i> is provided free of charge to the public
with the expectation of being a useful development tool for sharing
with the expectation of being a useful (not only) development tool for sharing
tables of structured data. The website hosts user-provided content
and disclaims any responsibility for the correctness of this data.
</p>
<h2>Usage of the Website</h2>
<p>
<i>datatable.directory</i> is meant as an alternative to sites like GitHub Gist
or Pastebin, and serves a similar purpose, that is, the sharing of data with others.
It is not a CDN, and it is not meant to be used by bots or scripts. Updating a table
with a script is allowed, but keep in mind that we maintain all historical revisions,
so updating a table too often may put a strain on the server. Think of it like
a GitHub optimized for tables, except on a much smaller server.
</p>
<p>
Please do not try to upload very large datasets (i.e. over 10.000 entries),
such as star catalogs, or the unicode character map, which are already
available elsewhere. These are generally better served by dedicated websites.
</p>
<p>
Further, we ask you to refrain from using the export API excessively, or scraping our
table pages, when the results are invariant and can be cached. Excessive
requests may result in blacklisting to protect the server.
</p>
<h2>Prohibited Content</h2>
<p>
The following categories of data are prohibited in the directory:
</p>
@ -23,20 +48,10 @@
<li>Personal information shared without consent ("doxxing")</li>
<li>Racist, sexist, or otherwise offensive content</li>
<li>Anything illegal or questionable
(such as links to pirated music, software, cracks, pornography, drug-related content etc.)</li>
(such as links to pirated music, software, cracks, pornography, drug-related content, etc.)</li>
</ul>
<p>
Please do not try to upload very large datasets (i.e. over 10.000 entries),
such as star catalogs, or the unicode character map, which are already
available elsewhere. These are generally better served by dedicated websites.
</p>
<p>
Further, we ask users to refrain from abusing the API or scraping the table pages
when the results are invariant and can be easily cached. Excessive requests
may result in user or IP ban to protect the server.
</p>
<h2>Abuse</h2>
<p>
Site operators reserve the right to hide or delete any objectionable

@ -1,4 +1,5 @@
@extends('layouts.app')
@section('title', "Login")
@section('content')
<div class="container">
@ -31,11 +32,11 @@
</form>
</div>
<div class="card-footer bg-white">
<div class="card-footer auth-footer">
<div class="form-group row mb-0">
<span class="col-md-4 col-form-label text-md-right text-muted">…or authenticate with</span>
<ul class="col-md-6 sr-list">
<ul class="col-md-8 sr-list">
{{-- end-li's deliberately missing to suppress spaces --}}
@set('services.oauth_providers.github.client_id')

@ -1,4 +1,5 @@
@extends('layouts.app')
@section('title', "Forgotten Password")
@section('content')
<div class="container">

@ -1,4 +1,5 @@
@extends('layouts.app')
@section('title', "Reset Password")
@section('content')
<div class="container">

@ -1,4 +1,5 @@
@extends('layouts.app')
@section('title', "Register")
@section('content')
<div class="container">
@ -37,7 +38,7 @@
</form>
</div>
<div class="card-footer bg-white text-muted">
<div class="card-footer auth-footer text-muted">
You can also register by logging in with
<a href="{{route('oauth-google-authorize')}}">Google</a>,
<a href="{{route('oauth-github-authorize')}}">GitHub</a>,

@ -8,7 +8,7 @@
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>@yield('title', 'MISSING TITLE') - datatable.directory</title>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
@ -24,7 +24,12 @@
@stack('scripts')
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
<link
rel="stylesheet"
id="theme-style"
href="{{ asset(dark_mode('css/app-dark.css', 'css/app.css')) }}"
data-light-url="{{ asset('css/app.css') }}"
data-dark-url="{{ asset('css/app-dark.css') }}">
<link href="{{ asset('fonts/fa-dtbl-1.css') }}" rel="stylesheet">
</head>
<body>

@ -13,9 +13,11 @@
<nav class="ml-auto d-block">
<h3 class="sr-only">Useful links:</h3>
<ul class="sr-list">
<li><a href="/about/privacy" class="ml-2">Privacy</a>
<li><a href="/about/terms" class="ml-2">Terms</a>
<li><a href="mailto:admin@datatable.directory?subject=Feedback" class="ml-2">Contact</a>
<li><a href="{{ route('privacy') }}" class="ml-2">Privacy</a>
<li><a href="{{ route('terms') }}" class="ml-2">Terms</a>
<li><a href="{{ route('faq') }}" class="ml-2">FAQ</a>
<li><a href="{{ route('donate') }}" class="ml-2">Donate</a>
<li><a href="{{ config('app.href.feedback') }}" class="ml-2">Feedback</a>
</ul>
</nav>
</nav>

@ -1,10 +1,19 @@
{{-- Global navbar --}}
<nav class="navbar navbar-expand-md navbar-light page-navbar">
<nav class="navbar navbar-expand-md navbar-{{ dark_mode('dark', 'light') }} page-navbar">
<div class="container">
<a class="navbar-brand" href="{{ route('dash') }}" aria-label="Go to Dashboard">
<img src="/images/logo.svg" aria-hidden=true alt="LOGO" height="32px" style="margin: -10px 0" class="mr-2">{{--
--}}datatable.directory
<img
id="navbar-logo"
src="/images/logo{{ dark_mode('-dark', '') }}.svg"
data-src-light="/images/logo.svg"
data-src-dark="/images/logo-dark.svg"
aria-hidden=true
alt="LOGO"
height="32px"
style="margin: -10px 0"
class="mr-2">{{--
--}}datatable.directory <sup><i>β</i></sup>
</a>
<button class="navbar-toggler noscript-hide" type="button" data-toggle="collapse"
@ -22,7 +31,6 @@
@else
{{-- Logged in --}}
@include('layouts.nav-buttons', ['dropdown' => false])
<li class="nav-item dropdown no-mobile">
<a id="mainNavDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" v-pre>
@ -43,6 +51,7 @@
</div>
<noscript>
@sr(Menu for users without JavaScript)
<ul class="navbar-nav ml-auto">
@guest
@include('layouts.guest-buttons')

@ -1,7 +1,7 @@
@php
$dmdhide = isset($noscript)?'':'d-md-none';
$li = $dropdown ? '' : "<li class=\"nav-item ml-1 $dmdhide\">";
$li = $dropdown ? '' : "<li class=\"nav-item ml-1 $dmdhide\" aria-hidden=\"true\">";
$aclass = $dropdown ? 'dropdown-item' : 'nav-link';
@endphp
@ -16,6 +16,19 @@ $aclass = $dropdown ? 'dropdown-item' : 'nav-link';
<i class="fa-key-modern fa-pr"></i>{{ __('Settings') }}
</a>
{!! $li !!}
<form method="POST" action="{{ route('toggle-dark-mode') }}" aria-hidden="true">
@csrf
<button id="toggle-dark-mode-btn" class="btn-link border-0 {{ $aclass }}" type="submit" onclick="event.preventDefault(); toggleDarkMode();">
<span class="dark-mode-switch-on">
<i class="fa-sun-o fa-pr"></i>{{ __('Light Mode') }}
</span>
<span class="dark-mode-switch-off">
<i class="fa-moon-o fa-pr"></i>{{ __('Dark Mode') }}
</span>
</button>
</form>
{!! $li !!}
<a class="{{ $aclass }}" href="{{ route('logout') }}"
onclick="event.preventDefault(); document.getElementById('logout-form').submit();">

@ -0,0 +1,69 @@
@php
/** @var \App\Models\User $user */
@endphp
<div class="card">
<div class="card-header card-header-extra">
@icon(fa-user-circle-o fa-pr fa-large)
<h1>
@if(authed() && user()->is($user))
@sr(Your dashboard -)
@else
@sr(User's page -)
@endif
{{ $user->title }}
</h1>
@if(authed() && user()->is($user))
<a href="{{route('profile.edit')}}" class="btn ml-auto" aria-label="Edit Profile">Edit</a>
@endif
</div>
<div class="card-body">
@if($user->bio)
<div class="mb-3">
{!! Widget::collapsible($user->bio, 350, '8em')->srPrefix('About me:') !!}
</div>
@endif
<div class="mb-3">
@if($tables_count)
<a href="{{ route('profile.view', $user->name) }}" class="btn btn-sm btn-outline-primary">
@icon(fa-table fa-pr)Tables <span class="badge">{{$tables_count}}</span>
</a>
@endif
@if($favourites_count)
<a href="{{ route('profile.view-favourites', $user->name) }}" class="btn btn-sm btn-outline-primary">
@icon(fa-star fa-pr)Favourites <span class="badge">{{$favourites_count}}</span>
</a>
@endif
</div>
<table class="bio-table">
<tbody>
@if($user->website)
<tr>
<td class="text-center pr-2">@icon(fa-link, User's Website:)</td>
<td>{!! Widget::tryLink($user->website) !!}</td>
</tr>
@endif
<tr>
<td class="text-center pr-2">@icon(fa-vcard-o, User's handle:)</td>
<td>{{ $user->handle }}</td>
</tr>
<tr>
<td class="text-center pr-2">@icon(fa-calendar, Join date:)</td>
<td>
<span class="pr-1" data-toggle="tooltip" data-placement="right"
title="{{ $user->created_at->format('M n, Y \\a\\t G:i') }}">
Joined {{ $user->created_at->diffForHumans() }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>

@ -8,7 +8,7 @@
<div class="list-group list-group-flush">
@if(count($tables) == 0)
<span class="list-group-item">No tables yet.</span>
<span class="list-group-item">Nothing to show yet.</span>
@else
@foreach($tables as $table)
@php
@ -24,24 +24,31 @@
@icon(fa-table fa-pr){!!
$showAuthors ? (e($table->owner->handle) . '<span class="px-1">/</span>') : ''
!!}<b>{{ $table->title }}</b>
</span>{{--
--}}<div class="d-block col-5 small text-right">{{--
--}}<span title="Visits" class="d-inline-block pl-2" style="min-width: 50px;">
{{ $table->visits }}@icon(fa-eye fa-pl, ~Visits~~)
</span>{{--
--}}{{--<span title="Forks" class="d-inline-block pl-2 {{$forks==0?'hidden':''}}" style="min-width: 50px;">
</span>
<div class="d-flex justify-content-end col-5 small text-right">
@if(FEATURE_FORKS)
<span title="Forks" class="d-inline-block pl-2 {{$forks==0?'hidden':''}}" style="min-width: 50px;">
{{ $forks }}@icon(fa-code-fork fa-pl, ~Forks~~)
</span>--}}{{--
--}}{{--<span title="Favourites" class="d-inline-block pl-2 {{$faves==0?'hidden':''}}" style="min-width: 50px;">
</span>
@endif
@if(FEATURE_FAVES)
<span title="Favourites" class="d-inline-block pl-2 {{$faves==0?'hidden':''}}" style="min-width: 50px;">
{{ $faves }}@icon(fa-star fa-pl, ~Favourites~~)
</span>--}}{{--
--}}{{--<span title="Revisions" class="d-inline-block pl-2" style="min-width: 50px;">
</span>
@endif
<span title="Visits" class="d-inline-block pl-2" style="min-width: 50px;">
{{ $table->visits }}@icon(fa-eye fa-pl, ~Visits~~)
</span>
<span title="Revisions" class="d-inline-block pl-2" style="min-width: 50px;">
{{ $revs }}@icon(fa-history fa-pl, ~Revisions~~)
</span>--}}{{--
--}}<span title="Rows" class="d-inline-block pl-2" style="min-width: 50px;">
</span>
<span title="Rows" class="d-inline-block pl-2" style="min-width: 50px;">
{{ $rows }}@icon(fa-th-list fa-pl, ~Rows)
</span>{{--
--}}
</span>
</div>
</span>

@ -1,6 +1,7 @@
{{-- Profile edit form --}}
@extends('layouts.app')
@section('title', "Account Settings")
@section('content')
@php(Widget::setLayout(3, 7))
@ -17,6 +18,7 @@
', 'text-muted', false) !!}
</div>
<div class="col-md-10 mt-3">
{!! Widget::header(2, 'Identifiers') !!}

@ -1,6 +1,7 @@
{{-- Profile edit form --}}
@extends('layouts.app')
@section('title', "Edit Profile")
@section('content')
<form method="POST" action="{{route('profile.store')}}" class="row justify-content-center"
@ -10,7 +11,7 @@
<div class="col-md-10">
@php(Widget::setLayout(3, 7))
{!! Widget::header(1, 'Profile Settings') !!}
{!! Widget::header(1, 'Edit Profile') !!}
{!! Widget::par('
Username can be changed on the
<a href="'.e(route('account.edit')).'">account settings</a> page.

@ -1,5 +1,6 @@
{{-- User's dashboard / profile page --}}
@extends('layouts.app')
@section('title', "$user->title's Tables")
@php
/** @var \App\Models\Table[] $tables */
@ -11,84 +12,58 @@
{{-- Dash card --}}
<div class="col-md-4">
@include('profile._profile-card')
</div>
{{-- Table list card --}}
<div class="col-md-8 mt-2 mt-md-0">
<div class="card">
<div class="card-header card-header-extra">
@icon(fa-user-circle-o fa-pr fa-large)
<h1>
@if(authed() && user()->is($user))
@sr(Your dashboard -)
@else
@sr(User's page -)
@endif
{{ $user->title }}
</h1>
@if($pageVariant == 'tables')
@icon(fa-table fa-pr fa-large)
<h2>
@if(authed() && user()->is($user))
<a href="{{route('profile.edit')}}" class="btn ml-auto" aria-label="Edit Profile">Edit</a>
@endif
</div>
<div class="card-body">
@if($user->bio)
<div class="mb-3">
{!! Widget::collapsible($user->bio, 350, '8em')->srPrefix('About me:') !!}
</div>
@endif
<table class="bio-table">
<tbody>
@if($user->website)
<tr>
<td class="text-center pr-2">@icon(fa-link, User's Website:)</td>
<td>{!! Widget::tryLink($user->website) !!}</td>
</tr>
Your Tables:
@else
User's Tables:
@endif
</h2>
<tr>
<td class="text-center pr-2">@icon(fa-vcard-o, User's handle:)</td>
<td>{{ $user->handle }}</td>
</tr>
<tr>
<td class="text-center pr-2">@icon(fa-table, Tables:)</td>
<td>{{ $tables_count }} table{{$tables_count!=1?'s':''}}</td>
</tr>
@include('table._sort-dropdown', [
'link' => route('profile.view',
Input::except(['tableSort', 'page'])
+ ['tableSort' => '__sort'] + ['user' => $user->name])
])
<tr>
<td class="text-center pr-2">@icon(fa-calendar, Join date:)</td>
<td>
<span class="pr-1" data-toggle="tooltip" data-placement="right"
title="{{ $user->created_at->format('M n, Y \\a\\t G:i') }}">
Joined {{ $user->created_at->diffForHumans() }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@elseif($pageVariant == 'favourites')
{{-- Table list card --}}
<div class="col-md-8 mt-2 mt-md-0">
<div class="card">
<div class="card-header card-header-extra">
@icon(fa-table fa-pr fa-large)
@icon(fa-star fa-pr fa-large)
<h2>
@if(authed() && user()->is($user))
Your Tables
Your Favourites:
@else
User's Tables
User's Favourites:
@endif
</h2>
@include('table._sort-dropdown', [
'link' => route('profile.view-favourites',
Input::except(['tableSort', 'page'])
+ ['tableSort' => '__sort'] + ['user' => $user->name])
])
@endif
<nav class="ml-auto" aria-label="Pages of the table list">
{{ $tables->links(null, ['ulClass' => 'pagination-sm mb-0 pagination-outline-light']) }}
</nav>
@if($pageVariant == 'tables')
@if(authed() && user()->is($user) && user()->confirmed)
<a href="{{route('table.create')}}" class="btn ml-3" aria-label="New Table">New</a>
@endif
@endif
</div>
@include('profile._table-list')

@ -0,0 +1,5 @@
<small class="flex-grow-1" style="font-size: 120%;" id="table-id">
<a href="{{ route('profile.view', $table->owner->name) }}" class="link-no-color">{{ $table->owner->handle }}</a>{{--
--}}<span class="px-1">/</span>{{--
--}}<b><a href="{{ $table->viewRoute }}" class="link-no-color">{{ $table->name }}</a></b>
</small>

@ -1,9 +1,11 @@
{{--
args: $table
--}}
@php
/** @var \App\Models\Table $table */
@endphp
<div class="row">
{{-- Description field --}}
<div class="col-md-8 pl-md-0">
<b>Description</b>
@ -67,7 +69,10 @@
<tr>
<th class="text-right pr-2">Revisions</th>
<td>{{ $table->revisions_count }}</td>
<td>
{{ $table->revisions_count }}
<span class="text-muted ml-1">(<a class="link-no-color" href="{{ $table->actionRoute('revisions') }}">see changes</a>)</span>
</td>
</tr>
<tr>

@ -3,7 +3,7 @@
--}}
@php
/** @var object[] $columns */
/** @var \App\Tables\Column[] $columns */
/** @var \App\Tables\Changeset[] $changeset */
/** @var \App\Models\Row[] $rows */
@endphp
@ -11,7 +11,6 @@
<table class="table table-hover table-sm">
<thead>
<tr>
<th>#</th>
@foreach($columns as $col)
<th title="{{$col->name}}">{{ $col->title }}</th>
@endforeach
@ -20,7 +19,6 @@
<tbody>
@foreach($rows as $row)
<tr>
<td>{{$row->_id}}</td>
@foreach($columns as $col)
<td>{{ $row->{$col->name} }}</td>
@endforeach

@ -0,0 +1,16 @@
<span>
<a class="nav-link dropdown-toggle link-no-color pl-1 ml-1 pr-2"
data-toggle="dropdown" href="#" role="button" aria-haspopup="true"
aria-expanded="false">{{ $sortOptions[$tableSort] }}</a>
<span class="dropdown-menu">
@foreach($sortOptions as $k => $label)
<a class="dropdown-item"
href="{{ str_replace('__sort', $k, $link) }}">
{{ $label }}
</a>
{{--@endif--}}
@endforeach
</span>
</span>
{{-- route('dash', Input::except(['tableSort', 'tablePage']) + ['tableSort' => $k]) --}}

@ -0,0 +1,10 @@
<div class="row justify-content-start px-3">
<div class="d-flex w-100 align-items-center">
@include('table._header-handle')
<h1 class="mx-3" id="table-title">{{ $table->title }}</h1>
<a href="{{ $table->viewRoute }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Back to Table)>
@icon(fa-reply, sr:Back to Table)
</a>
</div>
</div>

@ -9,24 +9,29 @@
<nav aria-label="Table action buttons">
@sr(Table actions)
@if(guest() || !user()->confirmed || user()->ownsTable($table))
@if(guest() || !user()->confirmed)
{{-- Guest, unconfirmed, or a table owner --}}
@if(FEATURE_FORKS)
{{-- Passive fork buttons with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Forks)>
@icon(fa-code-fork, sr:Forks)&nbsp;
{{ $table->forks_count ?: '–' }}
</a>
@endif
@if(FEATURE_FAVES)
{{-- Passive favourite buttons with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourites)>
<a href="{{ $table->actionRoute('favourites') }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourites)>
@icon(fa-star, sr:Favourites)&nbsp;
{{ $table->favourites_count ?: '–' }}
</a>
@endif
@else
{{-- Logged in and does not own the table --}}
@if(FEATURE_FORKS)
{{-- Active fork button | counter --}}
<div class="btn-group" role="group" aria-label="Fork">
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Fork)>
@ -36,36 +41,43 @@
{{ $table->forks_count ?: '–' }}
</a>
</div>
@endif
@if(FEATURE_FAVES)
{{-- Active favourite button | counter --}}
<div class="btn-group" role="group" aria-label="Favourite">
@if(user()->favouritesTable($table))
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Un-favourite)>
<a href="{{ $table->actionRoute('unfavourite') }}" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Un-favourite)>
@icon(fa-star, sr:Un-favourite)
</a>
@else
<a href="" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Favourite)>
<a href="{{ $table->actionRoute('favourite') }}" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Favourite)>
@icon(fa-star-o, sr:Favourite)
</a>
@endif
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourite Count)>
<a href="{{ $table->actionRoute('favourites') }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Favourite Count)>
{{ $table->favourites_count ?: '–' }}
</a>
</div>
@endif
@endif
@if(FEATURE_TABLE_COMMENTS)
{{-- Comments button with counter --}}
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Comments)>
@icon(fa-comment, sr:Comments)&nbsp;
{{ $table->comments_count ?: '–' }}
</a>
@endif
{{-- Active proposals button | counter --}}
<div class="btn-group" role="group" aria-label="Fork">
@if(FEATURE_PROPOSALS)
<a href="" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Change Proposals)>
@icon(fa-inbox fa-pr, sr:Change Proposals){{ $table->proposals_count ?: '–' }}
@icon(fa-inbox fa-pr, sr:Change Proposals){{ $proposals_count ?: '–' }}
</a>
@endif
@auth
@if(user()->ownsTable($table))
{{-- Table owner logged in --}}
@ -73,11 +85,13 @@
@icon(fa-pencil, sr:Draft Change)
</a>
@else
@if(FEATURE_PROPOSALS)
{{-- Not a table owner --}}
<a href="{{ $table->draftRoute }}" class="btn btn-outline-primary py-1 btn-sm btn-square" @tooltip(Propose Change)>
@icon(fa-pencil, sr:Propose Change)
</a>
@endif
@endif
@endauth
</div>

@ -1,6 +1,7 @@
{{-- Basic table view --}}
@extends('layouts.app')
@section('title', "Table Settings - \"$table->title\"")
@php
/** @var \App\Models\Table $table */
@ -18,7 +19,7 @@
<h1 class="mx-3">{{ $table->title }}</h1>
<a href="{{ $table->viewRoute }}" class="btn btn-outline-primary py-1 btn-sm" @tooltip(Back to Table)>
@icon(fa-table, sr:Back to Table)
@icon(fa-reply, sr:Back to Table)
</a>
</div>
</div>

@ -1,9 +1,11 @@
{{-- New table form --}}
@extends('layouts.app')
@section('title', "New Table")
@section('content')
<form method="POST" action="{{route('table.storeNew')}}" class="row justify-content-center"
enctype="multipart/form-data"
aria-label="New Table">
@csrf
@ -57,11 +59,15 @@
</div>
</div>
{!! Widget::textarea('data', 'Initial data')->value($exampleData)->height('12em')
->help('
Initial table data in CSV format, columns corresponding to the
specification you entered above. This is optional; you can fill the table
later, e.g. by uploading a CSV file.') !!}
{!! Widget::par('
Initialize the table with pasted CSV lines or an uploaded CSV file,
using the column order you defined above. This is optional, you can always
fill or modify the table later.
', 'text-muted') !!}
{!! Widget::textarea('data', 'CSV as text')->value($exampleData)->height('12em') !!}
{!! Widget::file('csv-file', 'CSV file')->accept("text/csv") !!}
<div class="row form-group">
<div class="col-md-7 offset-md-3">
@ -79,7 +85,9 @@
ready(function() {
app.ColumnEditor('#column-editor', {
name: 'columns',
initialColumns: {!! old('columns', toJSON($columns)) !!},
xColumns: @json(old_json('columns', $columns)),
newTable: true,
//sortable: false,
})
});
</script>

@ -0,0 +1,44 @@
{{-- List of table revisions --}}
@extends('layouts.app')
@section('title', "Favouriting Users - $table->title")
@php
/** @var \App\Models\Table $table */
/** @var \App\Models\User[]|\Illuminate\Support\Collection $users */
@endphp
@section('content')
@include('table._table-subpage-header')
<div class="row px-3 border-top pt-3">
<div class="col-md-12">
<h2>Favouriting Users</h2>
</div>
<div class="col-md-12 d-flex flex-wrap">
@foreach($users as $user)
<div class="col-md-4 px-0">
<div class="rounded border box-shadow-thin m-1 px-2 d-flex align-items-center py-1">
<span class="flex-grow-1" style="font-size:140%;font-weight:bold">
{{ $user->title }}
</span>
<span>
<a href="{{route('profile.view', $user->name)}}"
class="btn btn-outline-secondary btn-sm"
>
@icon(fa-user fa-pr)Profile
</a>
<a href="{{route('profile.view-favourites', $user->name)}}"
class="btn btn-outline-secondary btn-sm"
>
@icon(fa-star fa-pr)Faves
</a>
</span>
</div>
</div>
@endforeach
</div>
</div>
@endsection

@ -0,0 +1,50 @@
@php
$tab = 'add-rows';
/** @var \App\Tables\Column[] $columns */
/** @var \App\Tables\Changeset $changeset */
/** @var \App\Models\Row[]|Illuminate\Pagination\Paginator $rows */
/** @var \App\Models\Table $table */
@endphp
@extends('table.propose.layout')
@section('tab-content')
<form action="{{$table->draftUpdateRoute}}" method="POST" class="form col-md-12 mt-3" enctype="multipart/form-data">
@csrf
<input type="hidden" name="action" value="row.add-csv">
<input type="hidden" name="redirect" value="{{$table->getDraftRoute('add-rows')}}">
<?php Widget::setLayout(3,7) ?>
{!! Widget::header(3, "Import CSV") !!}
@php
$cols = [];
foreach ($columns as $column) {
$cols[] = '<b>'.e($column->title) . '</b>';
}
@endphp
{!! Widget::par('Append rows from pasted CSV lines or an uploaded CSV file') !!}
{{-- TODO interactive widget to select which cols are included, and in which order --}}
{!! Widget::labeledPar('Columns', implode(', ', $cols), '', false) !!}
{!! Widget::textarea('data', 'CSV as text')->help('
Comma-separated values, one row per line.
Do not put spaces after commas, they would be
included in the values.
')->minHeight('10em') !!}
{!! Widget::file('csv-file', 'CSV file')->accept("text/csv") !!}
<div class="row form-group">
<div class="col-md-7 offset-md-3">
<button class="btn btn-outline-success">
@icon(fa-upload fa-pr)Upload
</button>
</div>
</div>
</form>
@stop
@push('scripts')
@endpush

@ -7,45 +7,37 @@
@endphp
@extends('table.propose.layout-row-pagination')
@section('header')
<div class="form-inline py-2 px-1 border-bottom mb-3">
{{-- TODO improve layout --}}
<form action="{{$table->draftUpdateRoute}}" method="POST" class="form-inline">
@csrf
<input type="hidden" name="action" value="rows.add-csv">
<input type="hidden" name="redirect" value="{{request()->fullUrl()}}">
<label class="pr-2" for="csv-data">Add CSV:</label>
<textarea name="data" id="csv-data"
title="{{$errors->has('data') ? $errors->first('data') : ''}}"
class="form-control mr-1 {{ $errors->has('data')?'is-invalid':'' }}"
style="width:30em; height:10em">{{old('data')}}</textarea>
<button class="btn btn-outline-success">Append</button>
</form>
<div class="flex-grow-1"></div>
<form action="{{$table->draftUpdateRoute}}" method="POST">
@csrf
<input type="hidden" name="action" value="rows.add">
<input type="hidden" name="redirect" value="{{request()->fullUrl()}}">
<label class="form-inline pr-2" for="newrow-count">Add Empty Rows:</label>
<input name="count" id="newrow-count" type="number" min=1 step=1 value=1 class="form-control mr-1" style="width:10em">
<button class="btn btn-outline-success">Add</button>
</form>
</div>
@stop
@section('rows')
<div id="rows-editor"></div>
@stop
@push('scripts')
@php
$xrows = $rows->map(function($r) {
$r['_orig'] = $r;
return $r;
})->keyBy('_id');
@endphp
<script>
ready(function() {
app.RowsEditor('#rows-editor', {
route: {!! toJSON($table->draftUpdateRoute) !!},
columns: {!! toJSON($columns) !!},
xRows: {!! toJSON($rows->keyBy('_id'), true) !!},
var rows_editor = app.RowsEditor('#rows-editor', {
hasPages: @json($rows->hasPages()),
pager:
'<nav class="text-center" aria-label="Table pages">' +
@json((string)$rows->links(null, ['ulClass' => 'mb-0'])) +
'</nav>',
route: @json($table->draftUpdateRoute),
columns: @json($columns),
xRows: @json($xrows, true),
newRows: true, // indicate all are new
pageUrl: @json(request()->fullUrl()),
loadCsvUrl: @json($table->getDraftRoute('add-rows-csv')),
csrf: @json(csrf_token()),
})
$('#save-all-rows').on('click', function () {
rows_editor.saveAllChanges()
})
});
</script>

@ -23,9 +23,15 @@
<script>
ready(function() {
app.RowsEditor('#rows-editor', {
route: {!! toJSON($table->draftUpdateRoute) !!},
columns: {!! toJSON($columns) !!},
xRows: {!! toJSON($transformed, true) !!},
hasPages: @json($rows->hasPages()),
pager:
'<nav class="text-center" aria-label="Table pages">' +
@json((string)$rows->links(null, ['ulClass' => 'mb-0'])) +
'</nav>',
route: @json($table->draftUpdateRoute),
columns: @json($columns),
xRows: @json($transformed),
xRowOrder: @json($transformed->keys()),
})
});
</script>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save