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.