Od wielu miesięcy zmagam się ze znalezieniem w miarę prostego FileManager’a, którego mógłbym użyć w swoim CMSie, zarówno jako wersję standalone jak i jako plugin np. do TinyMce. Znalazłem kilka obiecujących propozycji i pomysłów, jednak doszedłem do wniosku, że chciałbym „odkryć koło na nowo”. Krótko mówiąc postanowiłem zrobić swój własny. No i jak to z takimi pomysłami bywa, jest chwila, jest zapał, a za miesiąc czy dwa wszystko przepada. Stąd postanowiłem się zmobilizować i wspólnie z Wami, czytelnikami mojego bloga, stworzyć prostą aplikację w AngularJS, która będzie realizowała moje założenia. Ponieważ problem jest dość złożony postaram się go zrealizować w odcinkach. Dziś pierwsza część: Ogólne założenia, konfiguracja i layout. Zatem zaczynamy.
Założenia
Celem projektu jest stworzenie aplikacji umożliwiającej:
- przeglądanie listy plików i katalogów wgranych na serwer
- możliwość wgrania nowych plików (multiupload)
- możliwość filtrowania plików: po typie czy po nazwie
- fajnym dodatkiem będzie to jeśli aplikację da radę uruchomić jako plugin do edytorów WYSIWYG
Konfiguracja
Skoro wiemy już co chcemy zrobić to czas pomyśleć o tym jak zautomatyzować nasz projekt. Bardzo łatwo zauważyć, że będą nam potrzebne zewnętrzne biblioteki JavaScript takie jak choćby AngularJS lub jQuery, czy frameworki CSS: Bootstrap i Fonts-Awesome.
Proponuję całość oprzeć o Grunt’a, który pozwoli nam: zainstalować zewnętrzne biblioteki przy pomocy Bower’a (grunt-bower-task), sprawdzać poprawność pisanego kodu (jshint), łączyć i minimalizować pliki (ulifyjs), uruchomić naszą aplikację na lokalnym serwerze (express) jak i parę jeszcze innych rzeczy o których zaraz napiszę.
NPM – lista potrzebnych pakietów
Zakładam, że każdy spotkał się już z nodejs i ma pojęcie o tym jak to działa, jeśli nie, to zapraszam do przeczytania moich artykułów na ten temat.
Głównym plikiem naszego projektu będzie: package.json. Zawiera on listę niezbędnych pakietów potrzebnych do uruchomienia naszej aplikacji.
{ "name": "filemanager",
"version": "0.0.1", "options": { "server": { "src": "server/src", "dest": "server/dest", "script": "server/app.js" }, "client": { "src": "client/src",
"dist": "client/dist" } }, "dependencies": { "grunt":"0.4.2", "grunt-contrib-jshint": "0.6.3", "grunt-contrib-uglify": "0.2.2", "grunt-contrib-watch": "0.6.1", "grunt-contrib-less": "0.11.0", "grunt-express-server": "0.4.17", "grunt-reload": "0.2.0", "grunt-bower-task": "0.3.4", "connect": "2.14.5" } }
Powyższy listing przedstawia zawartość pliku package.json. Zawiera on dwa ważne elementy:
- options – lista parametrów, z których będzie korzystał Grunt
- dependecies – lista potrzebnych pakietów nodejs
- grunt – moduł umożliwiający konfigurację i uruchamianie różnych zadań
- grunt-contrib-jshint – moduł umożliwiający walidację poprawności pisanego kodu
- grunt-contrib-uglify – moduł odpowiedzialny za minifikację kodu JS
- grunt-contrib-watch – moduł uruchamiający zadanie monitorujące zmiany plików (watcher)
- grunt-contrib-less – moduł pozwalający parsować plik .less i zamieniać go na plik .css
- grunt-express-server – moduł uruchamiający serwer nodejs
- grunt-reload – moduł przeładowujący stronę
- grunt-bower-task – moduł odpowiedzialny za zainstalowanie zależności bibliotek zewnętrznych
- connect – pozwoli nam w prosty sposób uruchomić naszą aplikację jako serwer
Grunt
Mając już zainstalowane wszystkie niezbędne pakiety, przyszedł czas na skonfigurowanie naszego „task runner”. Plikiem konfiguracjyjnym Grunt’a jest plik Gruntfile.js znajdujący się w głównym katalogu naszej aplikacji. Zanim jednak ustalimy jego zawartość potrzeba jeszcze kilku słów wyjaśnienia jak będzie wyglądał układ plików i katalogów naszej aplikacji:
/
|-- bower_components - komponenty bowera
|-- client - aplikacja (część kliencka)
| |-- components - lista niezbędnych komponentów z katalogu bower_components
| |-- data - folder z danymi w formacie json, grafikami i innymi statycznymi plikami
| |-- dist - wersja produkcyjna naszej aplikacji
| |-- src - wersja źródłowa naszej aplikacji
|-- node_modules - moduły nodejs
|-- server - część serwerowa naszej aplikacji
|-- bower.json
|-- Gruntfile.js
|-- package.json
|-- .jshintrc
Mając już świadomość struktury naszej aplikacji przejdźmy do pliku Gruntfile.js.
module.exports = function(grunt) { var SERVER_PORT = 3000; var path = require('path'); grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), bower: { install: { options: { targetDir: '<%= pkg.options.client.src %>/components', install: true, verbose: true, cleanTargetDir: true, cleanBowerDir: false, layout: 'byComponent' } } }, jshint: { options: { jshintrc: '.jshintrc' }, files: ['<%= pkg.options.client.src %>/js/app/**/*.js'] }, less: { dist: { options: { }, files: { "<%= pkg.options.client.dist %>/css/main.min.css": "<%= pkg.options.client.src %>/css/main.less" } } }, express: { options: { // Override defaults here background: true, delay: 500 }, dev: { options: { script: '<%= pkg.options.server.script %>' } } }, reload: { port: 8001, liveReload: {}, proxy: { host: 'localhost', port: SERVER_PORT // should match server.port config } }, uglify: { options: { mangle: false }, files: { src: ['<%= pkg.options.client.src %>/js/app/app.js', '<%= pkg.options.client.src %>/js/app/controllers.js'], dest: '<%= pkg.options.client.dist %>/js/app.min.js' } }, watch: { options: { spawn: false }, files: ['<%= pkg.options.client.src %>/js/src/app/**', '<%= pkg.options.client.src %>/css/**', '/index.html', '<%= pkg.options.client.src %>/data/**/*.json'], tasks: ['jshint', 'less', 'uglify', 'reload'] } }); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-contrib-less'); grunt.loadNpmTasks('grunt-bower-task');
grunt.loadNpmTasks('grunt-express-server'); grunt.loadNpmTasks('grunt-reload'); grunt.registerTask('install', ['bower:install']); // Default task(s). grunt.registerTask('default', ['jshint', 'less', 'express', 'uglify', 'reload', 'watch']); };
Nie zagłębiając się za bardzo w poszczególne parametry konfiguracji, postaram się przedstawić ogólne założenia kolejnych zadań grunt’a:
- pkg – zmienna przechowująca dane z pliku package.json
- bower – ma za zadanie zainstalować wszystkie zależności w katalogu client/src/components
- jshint – analizuje wszystkie pliki *.js w katalogu źródłowy aplikacji pod kątem ich poprawności zgodnie z konfiguracją w pliku .jshintrc
- less – analizuje plik client/src/css/main.less i zamienia go na plik client/dist/css/main.min.css
- express – uruchamia serwer na lokalnym porcie 3000
- reload – pozwala odświeżyć stronę jeśli nastąpi jakaś zmiana w plikach
- uglifyjs – minimalizuje kod naszej aplikacji do jednego pliku js: client/dist/js/app.min.js
- watch – nasłuchuje zmian na różnych plikach i uruchamia odpowiednie zadania
Bower
Ostatnią rzeczą jaka pozostała nam do skonfigurowania jest lista zewnętrznych bibliotek JS i CSS, które chcemy wykorzystać w naszym projekcie. Lista ta przedstawia się w następująco:
{ "name": "filemanager", "directory": "client/js/components", "dependencies": { "bootstrap": "latest", "font-awesome-bower": "*", "jquery": "*", "jqueryui": "*", "angular-bootstrap": "*", "angular": "1.2.*", "angular-resource": "1.2.*", "angular-route": "1.2.*", "angular-mocks": "1.2.*", "angular-animate": "1.2.*", "angular-sanitize": "1.2.*", "angular-i18n": "1.2.*", "angular-ui-router": "latest", "lodash": "2.4.*", "ng-flow": "*" } ,"exportsOverride": { "angular-bootstrap": { "./": ["**"] }, "bootstrap": { "js": "dist/**/*.min.js", "css": "dist/**/*.min.css", "fonts": "dist/**/glyphicons*" }, "font-awesome-bower": { "css": "**/*.css", "fonts": "**/*-webfont*" } } }
Pobieramy w nim:
- bootstrap i fonts-awesome – chyba nie trzeba przedstawiać – framework CSS + zestaw ikon
- jquery i jquery-ui – tak na wszelki wypadek, jeśli nie użyję to na końcu wywalę, po prostu są to tak oczywiste biblioteki, ze lubię zawsze je mieć w swoim projekcie
- angularjs i wszystkie jego podstawowe moduły
- lodash – ma trochę ciekawych funkcji
- ng-flow – biblioteka ułatwiająca ładowanie plików
Instalacja
Dzięki takiej automatyzacji instalacja naszej aplikacji jest już dziecinnie prosta.
- Instalujemy potrzebne pakiety nodejs: npm install
- Instalujemy zależności bowera: grunt bower
- Uruchamiamy aplikację: grunt
W tej chwili powinniśmy mieć dostępny serwer: http://localhost:3000, jednak jeszcze nic nie stworzyliśmy zatem nasz serwer nie działa.
Serwer
Pierwszą częścią naszej aplikacji będzie prosty serwer, którego jedynym zadaniem jest serwowanie tego co się znajduje w katalogu client. Taką funkcjonalność zrealizujemy poniższym plikiem app.js.
var connect = require('connect'); connect.createServer( connect.static(__dirname + '/../client/') ).listen(3000);
Klient
Główny plik naszej aplikacji to client/src/app/app.js.
'use strict'; var filemanager = angular.module('filemanager', []);
Tworzy on na razie tylko moduł filemanager i nic poza tym. W przyszłości na pewno ulegnie zmianie.
Pierwszy kontroler naszej aplikacji zostanie umieszczony w pliku client/src/app/controllers.js.
'use strict'; angular.module('filemanager') .controller('MainCtrl', ['$scope', '$http', function($scope, $http){ $scope.dirs = []; $scope.files = []; $http.get('/data/directory.json') .success(function(data){ $scope.dirs = data.dirs; $scope.files = data.files; }) ; }]) ;
Zawiera on dwie zmienne $scope.dirs i $scope.files, które przechowują listę katalogów i listę plików znajdujących się w obecnie przeglądanym katalogu. Na tę chwilę dla ułatwienia zapełnienia tych zmiennych danymi pobieramy statyczne dane z pliku /data/directory.json. Jego skróconą wersję prezentuję poniżej:
{ "dirs": [ { "id": 1, "name": "Katalog" }, { "id": 2, "name": "Katalog o bardzo długiej nazwie" } ], "files": [ { "id": 1, "name": "Dino 1", "src": "/data/images/IMG_5549.JPG" }, { "id": 10, "name": "Dino 2", "src": "/data/images/IMG_5554.JPG" }
... ] }
Teraz przydałby się nam jeszcze jakiś plik html, który spiąłby to wszystko razem. W tym celu utworzymy plik index.html o następującej treści.
<html ng-app="filemanager">
<head>
<link rel="stylesheet" href="components/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="components/font-awesome-bower/css/font-awesome.min.css">
<link rel="stylesheet" href="/dist/css/main.min.css">
<script src="components/jquery/jquery.js"></script>
<script src="components/bootstrap/js/bootstrap.min.js"></script>
<script src="components/angular/angular.js"></script>
<script src="components/angular-route/angular-route.js"></script>
<script src="components/angular-resource/angular-resource.js"></script>
<script src="components/angular-bootstrap/ui-bootstrap.js"></script>
<script src="components/angular-bootstrap/ui-bootstrap-tpls.js"></script>
<script src="/dist/js/app.min.js"></script>
<script>__reloadServerUrl="ws://localhost:8001";</script>
<script type="text/javascript" src="//localhost:8001/__reload/client.js"></script>
</head>
<body>
<div data-ng-controller="MainCtrl" class="container filemanager">
<h1>Filemanager</h1>
<div class="row nav-row">
<div class="col-sm-3 col-md-3 text-left">
<div class="btn-group">
<button class="btn btn-default" title="Utwórz katalog"><i class="fa fa-plus"></i><i class="fa fa-folder-o"></i></button>
<button class="btn btn-default" title="Wgraj pliki"><i class="fa fa-plus"></i><i class="fa fa-files-o"></i></button>
</div>
</div>
<div class="col-sm-4 col-md-4 text-center">
<div class="btn-group">
<button class="btn btn-default active" title="Wszystkie typy plików"><i class="fa fa-file-o"></i></button>
<button class="btn btn-default" title="Zdjęcia"><i class="fa fa-picture-o"></i></button>
<button class="btn btn-default" title="Audio"><i class="fa fa-music"></i></button>
<button class="btn btn-default" title="Wideo"><i class="fa fa-video-camera"></i></button>
<button class="btn btn-default" title="Archiwa"><i class="fa fa-archive"></i></button>
</div>
</div>
<div class="col-sm-4 col-md-4 col-md-push-1 col-sm-push-1">
<div class="input-group">
<input type="text" class="form-control" placeholder="Wyszukaj">
<span class="btn btn-default input-group-addon"><i class="fa fa-search"></i></span>
</div>
</div>
</div>
<div class="panel panel-default main-panel">
<div class="panel-body">
<div ng-repeat="dir in dirs" class="thumb thumb-folder img-thumbnail">
<i class="thumb-image fa fa-folder-o"></i>
<div class="thumb-name" data-ng-bind="dir.name"></div>
</div>
<div ng-repeat="file in files" class="thumb thumb-file img-thumbnail">
<img class="thumb-image" ng-src="{{ file.src }}">
<div class="thumb-name" data-ng-bind="file.name"></div>
</div>
</div>
</div>
</div>
</body>
</html>
Myślę, że plik jest na tyle jasny, że nie trzeba szczególnie omawiać jego struktury. Niemniej jednak muszę wspomnieć o jednej rzeczy. Dwie ostatnie linijki <script>, pozwalają na połączenie się z grunt’owym modułem reload i automatyczne odświeżanie strony poprzez websocket.
Pozostało nam jeszcze okraszenie naszego projektu plikiem css powstałym z odpowiedniego pliku less – client/src/css/main.less
@containerClass: filemanager; @thumbWidth: 180px; @thumbHeight: 140px; @thumbNameHeight: 30px; .@{containerClass} { .nav-row { margin: 0 0 10px 0; div:first-child { margin-left: 0; padding-left: 0; } div:last-child { margin-right: 0; padding-right: 0; } } .btn { height: 34px; // Ikonki w przyciskach .fa { margin-right: 5px; &:last-child { margin-right: 2px; } } } .main-panel { .thumb { width: @thumbWidth; height: @thumbHeight; margin: 20px; .thumb-image { height: @thumbHeight - @thumbNameHeight - 10; } .thumb-name { height: @thumbNameHeight; line-height: @thumbNameHeight; background-color: #eeeeee; border-radius: 3px; padding: 0 3px; overflow: hidden; white-space:nowrap; text-overflow: ellipsis; text-align: center; } &.thumb-folder { text-align: center; .thumb-image { &.fa { font-size: @thumbHeight - @thumbNameHeight; } } } &.thumb-file { .thumb-image { width: @thumbWidth - 10; border-radius: 3px; } } } } }
Teraz wykonując polecenie: grunt, wszystko powinno się już prawidłowo uruchomić.
Podsumowanie
W ten oto sposób dobrnęliśmy do końca naszej pierwszej części. Aby wszystkim ułatwić życie proponuję skorzystać z repozytorium na githubie uruchomione specjalnie na potrzeby tego projektu: https://github.com/qjon/filemanager. Jeśli macie jakieś propozycje co do tego projektu proszę o informacje, sugestie, a także krytykę (oczywiście tą konstruktywną) na mój adres mailowy.