Initial commit, part deux
This commit is contained in:
parent
4a7beace84
commit
fd5ee1420c
10 changed files with 1463 additions and 0 deletions
39
src/collection.rs
Normal file
39
src/collection.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use std :: path :: * ;
|
||||
|
||||
|
||||
|
||||
pub struct Collection {
|
||||
pub films : Vec <Film> ,
|
||||
pub series : Vec <Series> ,
|
||||
}
|
||||
|
||||
pub struct Film {
|
||||
pub uuid : String ,
|
||||
pub name : String ,
|
||||
pub original_name : Option <String> ,
|
||||
pub release_date : String , // TODO: Switch to chrono types, I think rusqlite has crate option for it
|
||||
pub runtime_minutes : u32 ,
|
||||
pub poster_file_path : Option <PathBuf> ,
|
||||
pub video_file_path : Option <PathBuf> ,
|
||||
}
|
||||
|
||||
pub struct Series {
|
||||
pub uuid : String ,
|
||||
pub name : String ,
|
||||
pub original_name : Option <String> ,
|
||||
pub poster_file_path : Option <PathBuf> ,
|
||||
}
|
||||
|
||||
pub struct Season <'l> {
|
||||
pub uuid : String ,
|
||||
pub name : Option <String> ,
|
||||
pub series : & 'l Series ,
|
||||
}
|
||||
|
||||
pub struct Episode <'l> {
|
||||
pub uuid : String ,
|
||||
pub name : Option <String> ,
|
||||
pub release_date : String ,
|
||||
pub season : & 'l Season <'l> ,
|
||||
pub video_file_path : Option <PathBuf> ,
|
||||
}
|
36
src/main.rs
Normal file
36
src/main.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
mod collection ;
|
||||
mod persistence ;
|
||||
mod ui ;
|
||||
mod utility ;
|
||||
|
||||
use {
|
||||
gtk4 :: { gio :: spawn_blocking , glib :: * } ,
|
||||
libadwaita :: { * , prelude :: * } ,
|
||||
} ;
|
||||
|
||||
use crate :: { persistence :: * , ui :: * } ;
|
||||
|
||||
|
||||
|
||||
fn main () -> ExitCode {
|
||||
let application = Application :: builder ()
|
||||
. application_id ("com.kernelmaft.zoodex")
|
||||
. build () ;
|
||||
application . connect_activate (on_activate) ;
|
||||
application . run ()
|
||||
}
|
||||
|
||||
fn on_activate ( app : & Application ) {
|
||||
let mut ui = UI :: new (app) ;
|
||||
|
||||
let collection_handle = spawn_blocking ( ||
|
||||
read_collection_file () . unwrap ()
|
||||
) ;
|
||||
|
||||
ui . show_window () ;
|
||||
|
||||
spawn_future_local ( async move {
|
||||
let collection = collection_handle . await . unwrap () ;
|
||||
ui . render_collection (collection) ;
|
||||
} ) ;
|
||||
}
|
51
src/persistence.rs
Normal file
51
src/persistence.rs
Normal file
|
@ -0,0 +1,51 @@
|
|||
use { fallible_iterator :: * , rusqlite :: * } ;
|
||||
|
||||
use crate :: collection :: * ;
|
||||
|
||||
|
||||
|
||||
pub fn read_collection_file () -> Result <Collection> {
|
||||
let sqlite_connection = Connection :: open ("zoodex.sqlite") ? ;
|
||||
|
||||
let mut films_query = sqlite_connection
|
||||
. prepare ("select * from films order by release_date desc") ? ;
|
||||
let films = films_query . query (()) ? . map ( |row| {
|
||||
let uuid = row . get (0) ? ;
|
||||
let name = row . get (1) ? ;
|
||||
let original_name = row . get (2) ? ;
|
||||
let release_date = row . get (3) ? ;
|
||||
let runtime_minutes = row . get (4) ? ;
|
||||
let poster_file_path : Option <String> = row . get (5) ? ;
|
||||
let video_file_path : Option <String> = row . get (6) ? ;
|
||||
|
||||
Ok ( Film {
|
||||
uuid ,
|
||||
name ,
|
||||
original_name ,
|
||||
release_date ,
|
||||
runtime_minutes ,
|
||||
poster_file_path : poster_file_path . map ( String :: into ) ,
|
||||
video_file_path : video_file_path . map ( String :: into ) ,
|
||||
} )
|
||||
} ) ;
|
||||
|
||||
let mut series_query = sqlite_connection . prepare ("select * from series") ? ;
|
||||
let series = series_query . query (()) ? . map ( |row| {
|
||||
let uuid = row . get (0) ? ;
|
||||
let name = row . get (1) ? ;
|
||||
let original_name = row . get (2) ? ;
|
||||
let poster_file_path : Option <String> = row . get (3) ? ;
|
||||
|
||||
Ok ( Series {
|
||||
uuid ,
|
||||
name ,
|
||||
original_name ,
|
||||
poster_file_path : poster_file_path . map ( String :: into ) ,
|
||||
} )
|
||||
} ) ;
|
||||
|
||||
Ok ( Collection {
|
||||
films : films . collect () ? ,
|
||||
series : series . collect () ? ,
|
||||
} )
|
||||
}
|
115
src/ui/collection_menu.rs
Normal file
115
src/ui/collection_menu.rs
Normal file
|
@ -0,0 +1,115 @@
|
|||
use {
|
||||
gtk4 :: {
|
||||
Image , Label , ListBox , ListBoxRow , Popover ,
|
||||
Align :: * ,
|
||||
Orientation :: * ,
|
||||
prelude :: * ,
|
||||
} ,
|
||||
libadwaita :: * ,
|
||||
} ;
|
||||
|
||||
use crate :: ui :: { internal :: * , utility :: * } ;
|
||||
|
||||
|
||||
|
||||
pub fn create_film_collection_menu <F> ( on_sort : F ) -> gtk4 :: Box
|
||||
where F : Fn (FilmsSortedBy) + 'static {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation (Horizontal)
|
||||
. halign (Center)
|
||||
. spacing (20)
|
||||
. css_classes ( ["toolbar"] )
|
||||
. build () ;
|
||||
|
||||
container . append ( & create_sort_button ( & create_films_sort_menu (on_sort) ) ) ;
|
||||
container . append ( & SplitButton :: builder () . label ("Filter") . build () ) ;
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
pub fn create_series_collection_menu ( on_sort : fn (SeriesSortedBy) ) -> gtk4 :: Box {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation (Horizontal)
|
||||
. halign (Center)
|
||||
. spacing (20)
|
||||
. css_classes ( ["toolbar"] )
|
||||
. build () ;
|
||||
|
||||
container . append ( & create_sort_button ( & create_series_sort_menu (on_sort) ) ) ;
|
||||
container . append ( & SplitButton :: builder () . label ("Filter") . build () ) ;
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
fn create_sort_button ( sort_menu : & Popover ) -> SplitButton {
|
||||
SplitButton :: builder ()
|
||||
. child ( & create_label ("Sort") )
|
||||
. popover (sort_menu)
|
||||
. build ()
|
||||
}
|
||||
|
||||
fn create_films_sort_menu <F> ( on_sort : F ) -> Popover
|
||||
where F : Fn (FilmsSortedBy) + 'static {
|
||||
let container = ListBox :: new () ;
|
||||
|
||||
container . append ( & create_sort_menu_entry ( "Name" , false , true ) ) ;
|
||||
container . append ( & create_sort_menu_entry ( "Release date" , false , false ) ) ;
|
||||
container . append ( & create_sort_menu_entry ( "Runtime" , false , false ) ) ;
|
||||
|
||||
container . select_row ( container . row_at_index (0) . as_ref () ) ;
|
||||
|
||||
container . connect_row_activated ( move | _ , row | on_sort ( match row . index () {
|
||||
0 => FilmsSortedBy :: Name ,
|
||||
1 => FilmsSortedBy :: ReleaseDate ,
|
||||
2 => FilmsSortedBy :: Runtime ,
|
||||
_ => panic ! () ,
|
||||
} ) ) ;
|
||||
|
||||
Popover :: builder ()
|
||||
. child ( & container )
|
||||
. css_classes ( ["menu"] )
|
||||
. build ()
|
||||
}
|
||||
|
||||
fn create_series_sort_menu ( on_sort : fn (SeriesSortedBy) ) -> Popover {
|
||||
let container = ListBox :: new () ;
|
||||
|
||||
container . append ( & create_sort_menu_entry ( "Name" , false , true ) ) ;
|
||||
container . append ( & create_sort_menu_entry ( "First release date" , false , false ) ) ;
|
||||
|
||||
container . select_row ( container . row_at_index (0) . as_ref () ) ;
|
||||
|
||||
container . connect_row_activated ( move | _ , row | on_sort ( match row . index () {
|
||||
0 => SeriesSortedBy :: Name ,
|
||||
1 => SeriesSortedBy :: FirstReleaseDate ,
|
||||
_ => panic ! () ,
|
||||
} ) ) ;
|
||||
|
||||
Popover :: builder ()
|
||||
. child ( & container )
|
||||
. css_classes ( ["menu"] )
|
||||
. build ()
|
||||
}
|
||||
|
||||
fn create_sort_menu_entry ( label : & str , reverse : bool , selected : bool ) -> ListBoxRow {
|
||||
let icon = match reverse {
|
||||
false => Image :: from_icon_name ("view-sort-ascending-symbolic") ,
|
||||
true => Image :: from_icon_name ("view-sort-descending-symbolic") ,
|
||||
} ;
|
||||
if ! selected { icon . set_opacity (0.0) }
|
||||
|
||||
let container = create_horizontal_box ! (
|
||||
& Label :: builder ()
|
||||
. label (label)
|
||||
. hexpand (true)
|
||||
. halign (Start)
|
||||
. build () ,
|
||||
& icon ,
|
||||
) ;
|
||||
container . set_spacing (20) ;
|
||||
|
||||
// TODO : Highlight `:selected` row using CSS, parent with `.menu` prevents default behaviour
|
||||
|
||||
ListBoxRow :: builder () . child ( & container ) . build ()
|
||||
}
|
||||
|
269
src/ui/internal.rs
Normal file
269
src/ui/internal.rs
Normal file
|
@ -0,0 +1,269 @@
|
|||
use {
|
||||
gtk4 :: {
|
||||
FlowBox,
|
||||
HeaderBar,
|
||||
Image,
|
||||
Justification,
|
||||
Label,
|
||||
ScrolledWindow,
|
||||
SelectionMode,
|
||||
Widget,
|
||||
Align :: * ,
|
||||
Orientation :: * ,
|
||||
gdk :: Texture ,
|
||||
prelude :: * ,
|
||||
} ,
|
||||
libadwaita :: { * , ViewSwitcherPolicy :: * } ,
|
||||
std :: { cell :: * , path :: * } ,
|
||||
} ;
|
||||
|
||||
use crate :: {
|
||||
collection :: * ,
|
||||
ui :: { collection_menu :: * , utility :: * } ,
|
||||
utility :: * ,
|
||||
} ;
|
||||
|
||||
|
||||
|
||||
pub fn create_window (
|
||||
application : & Application ,
|
||||
header_bar : & HeaderBar ,
|
||||
collection_view_stack : & ViewStack ,
|
||||
) -> ApplicationWindow {
|
||||
let content = gtk4 :: Box :: builder () . orientation (Vertical) . build () ;
|
||||
content . append (header_bar) ;
|
||||
content . append (collection_view_stack) ;
|
||||
|
||||
ApplicationWindow :: builder ()
|
||||
. application (application)
|
||||
. content ( & content )
|
||||
. title ("Zoödex")
|
||||
. build ()
|
||||
}
|
||||
|
||||
pub fn create_header_bar ( collection_view_stack : & ViewStack ) -> HeaderBar {
|
||||
HeaderBar :: builder ()
|
||||
. title_widget (
|
||||
& create_collection_view_switcher ( collection_view_stack ) ,
|
||||
)
|
||||
. build ()
|
||||
}
|
||||
|
||||
pub fn create_collection_view_switcher ( stack : & ViewStack ) -> ViewSwitcher {
|
||||
ViewSwitcher :: builder ()
|
||||
. policy (Wide)
|
||||
. stack (stack)
|
||||
. build ()
|
||||
}
|
||||
|
||||
pub struct FilmsFlowBox { widget : FlowBox }
|
||||
pub struct SeriesFlowBox { widget : FlowBox }
|
||||
|
||||
impl FilmsFlowBox {
|
||||
pub fn new ( films : & [Film] ) -> Self {
|
||||
let widget = create_collection_flow_box () ;
|
||||
|
||||
for film in films {
|
||||
widget . append ( & create_film_item (film) ) ;
|
||||
}
|
||||
|
||||
Self { widget }
|
||||
}
|
||||
|
||||
pub fn set_films ( & self , films : & [Film] ) {
|
||||
self . widget . remove_all () ;
|
||||
for film in films {
|
||||
self . widget . append ( & create_film_item (film) ) ;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_widget ( & self ) -> & FlowBox { & self . widget }
|
||||
}
|
||||
impl SeriesFlowBox {
|
||||
pub fn new ( series : & [Series] ) -> Self {
|
||||
let widget = create_collection_flow_box () ;
|
||||
|
||||
for series in series {
|
||||
widget . append ( & create_series_item (series) ) ;
|
||||
}
|
||||
|
||||
Self { widget }
|
||||
}
|
||||
|
||||
pub fn set_series ( & self , series : & [Series] ) {
|
||||
self . widget . remove_all () ;
|
||||
for series in series {
|
||||
self . widget . append ( & create_series_item (series) ) ;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_widget ( & self ) -> & FlowBox { & self . widget }
|
||||
}
|
||||
|
||||
fn create_collection_flow_box () -> FlowBox {
|
||||
FlowBox :: builder ()
|
||||
. orientation (Horizontal)
|
||||
. homogeneous (true)
|
||||
. selection_mode ( SelectionMode :: None )
|
||||
. build ()
|
||||
}
|
||||
|
||||
pub enum FilmsSortedBy { Name , ReleaseDate , Runtime }
|
||||
pub enum SeriesSortedBy { Name , FirstReleaseDate }
|
||||
|
||||
pub struct FilmsContainer {
|
||||
films : Vec <Film> ,
|
||||
flow_box : FilmsFlowBox ,
|
||||
widget : gtk4 :: Box ,
|
||||
sorted_by : & 'static Cell <FilmsSortedBy> ,
|
||||
}
|
||||
pub struct SeriesContainer {
|
||||
series : Vec <Series> ,
|
||||
flow_box : SeriesFlowBox ,
|
||||
widget : gtk4 :: Box ,
|
||||
}
|
||||
|
||||
impl FilmsContainer {
|
||||
pub fn new ( films : Vec <Film> ) -> Self {
|
||||
let flow_box = FilmsFlowBox :: new ( films . as_slice () ) ;
|
||||
let sorted_by = leak ( Cell :: new ( FilmsSortedBy :: Name ) ) ;
|
||||
let widget = create_vertical_box ! (
|
||||
& create_film_collection_menu ( |new_sorted_by| {
|
||||
sorted_by . replace (new_sorted_by) ;
|
||||
} ) ,
|
||||
& create_collection_scrolled_window ( flow_box . get_widget () ) ,
|
||||
) ;
|
||||
|
||||
Self { films , flow_box , widget , sorted_by }
|
||||
}
|
||||
|
||||
pub fn set_films ( & mut self , films : Vec <Film> ) {
|
||||
self . films = films ;
|
||||
self . flow_box . set_films ( self . films . as_slice () ) ;
|
||||
}
|
||||
|
||||
pub fn get_widget ( & self ) -> & gtk4 :: Box { & self . widget }
|
||||
}
|
||||
impl SeriesContainer {
|
||||
pub fn new ( series : Vec <Series> ) -> Self {
|
||||
let flow_box = SeriesFlowBox :: new ( series . as_slice () ) ;
|
||||
let widget = create_vertical_box ! (
|
||||
& create_series_collection_menu ( |sorted_by| {
|
||||
// TODO
|
||||
} ) ,
|
||||
& create_collection_scrolled_window ( flow_box . get_widget () ) ,
|
||||
) ;
|
||||
|
||||
Self { series, flow_box , widget }
|
||||
}
|
||||
|
||||
pub fn set_series ( & mut self , series : Vec <Series> ) {
|
||||
self . series = series ;
|
||||
self . flow_box . set_series ( self . series . as_slice () ) ;
|
||||
}
|
||||
|
||||
pub fn get_widget ( & self ) -> & gtk4 :: Box { & self . widget }
|
||||
}
|
||||
|
||||
fn create_collection_scrolled_window ( child : & impl IsA <Widget> ) -> ScrolledWindow {
|
||||
ScrolledWindow :: builder ()
|
||||
. child ( & create_vertical_filler_container (child) )
|
||||
. propagate_natural_height (true)
|
||||
. build ()
|
||||
}
|
||||
|
||||
pub fn create_film_item ( film : & Film ) -> gtk4 :: Box {
|
||||
create_collection_item (
|
||||
film . name . as_str () ,
|
||||
film . original_name . as_deref () ,
|
||||
film . poster_file_path . as_deref () ,
|
||||
& create_film_details (film) ,
|
||||
)
|
||||
}
|
||||
pub fn create_series_item ( series : & Series ) -> gtk4 :: Box {
|
||||
create_collection_item (
|
||||
series . name . as_str () ,
|
||||
series . original_name . as_deref () ,
|
||||
series . poster_file_path . as_deref () ,
|
||||
& create_series_details (series) ,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_film_details ( film : & Film ) -> gtk4 :: Box {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation (Horizontal)
|
||||
. halign (Center)
|
||||
. spacing (20)
|
||||
. build () ;
|
||||
|
||||
container . append (
|
||||
& Label :: builder () . label ( & film . release_date ) . build ()
|
||||
) ;
|
||||
container . append (
|
||||
& Label :: builder () . label ( format ! ( "{}m" , film . runtime_minutes ) ) . build ()
|
||||
) ;
|
||||
|
||||
container
|
||||
}
|
||||
fn create_series_details ( series : & Series ) -> gtk4 :: Box {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation (Horizontal)
|
||||
. halign (Center)
|
||||
. spacing (20)
|
||||
. build () ;
|
||||
|
||||
// TODO
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
fn create_collection_item (
|
||||
name : & str ,
|
||||
original_name : Option < & str > ,
|
||||
poster_file_path : Option < & Path > ,
|
||||
details_widget : & gtk4 :: Box ,
|
||||
) -> gtk4 :: Box {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation (Vertical)
|
||||
. margin_top (20)
|
||||
. margin_bottom (20)
|
||||
. build () ;
|
||||
|
||||
if let Some (poster_file_path) = poster_file_path {
|
||||
if let Ok (poster_texture) = Texture :: from_filename (poster_file_path) {
|
||||
container . append (
|
||||
& Image :: builder ()
|
||||
. paintable ( & poster_texture )
|
||||
. width_request (300)
|
||||
. height_request (300)
|
||||
. margin_bottom (10)
|
||||
. build ()
|
||||
) ;
|
||||
}
|
||||
}
|
||||
|
||||
container . append (
|
||||
& Label :: builder ()
|
||||
. label ( format ! ( "<span size='large' weight='bold'>{}</span>" , name ) )
|
||||
. use_markup (true)
|
||||
. justify ( Justification :: Center )
|
||||
. wrap (true)
|
||||
. max_width_chars (1) // Not the actual limit, used instead to wrap more aggressively
|
||||
. build ()
|
||||
) ;
|
||||
|
||||
if let Some (original_name) = original_name {
|
||||
container . append (
|
||||
& Label :: builder ()
|
||||
. label (original_name)
|
||||
. justify ( Justification :: Center )
|
||||
. wrap (true)
|
||||
. max_width_chars (1) // Not the actual limit, used instead to wrap more aggressively
|
||||
. build ()
|
||||
) ;
|
||||
}
|
||||
|
||||
container . append (details_widget) ;
|
||||
|
||||
container
|
||||
}
|
37
src/ui/mod.rs
Normal file
37
src/ui/mod.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
mod collection_menu ;
|
||||
mod internal ;
|
||||
mod utility ;
|
||||
|
||||
use { gtk4 :: prelude :: * , libadwaita :: * } ;
|
||||
|
||||
use crate :: { collection :: * , ui :: { internal :: * , utility :: * } } ;
|
||||
|
||||
|
||||
|
||||
pub struct UI {
|
||||
window : ApplicationWindow ,
|
||||
films_container : FilmsContainer ,
|
||||
series_container : SeriesContainer ,
|
||||
}
|
||||
|
||||
impl UI {
|
||||
pub fn new ( application : & Application ) -> UI {
|
||||
let films_container = FilmsContainer :: new ( vec ! () ) ;
|
||||
let series_container = SeriesContainer :: new ( vec ! () ) ;
|
||||
let collection_view_stack = create_view_stack ! (
|
||||
"Films" , "camera-video-symbolic" , films_container . get_widget () ,
|
||||
"Series" , "video-display-symbolic" , series_container . get_widget () ,
|
||||
) ;
|
||||
let header_bar = create_header_bar ( & collection_view_stack ) ;
|
||||
let window = create_window ( application , & header_bar , & collection_view_stack ) ;
|
||||
|
||||
UI { window , films_container , series_container }
|
||||
}
|
||||
|
||||
pub fn show_window ( & self ) { self . window . set_visible (true) }
|
||||
|
||||
pub fn render_collection ( & mut self , collection : Collection ) {
|
||||
self . films_container . set_films ( collection . films ) ;
|
||||
self . series_container . set_series ( collection . series ) ;
|
||||
}
|
||||
}
|
45
src/ui/utility.rs
Normal file
45
src/ui/utility.rs
Normal file
|
@ -0,0 +1,45 @@
|
|||
use { gtk4 :: { Label , Widget , prelude :: * } , libadwaita :: * } ;
|
||||
|
||||
|
||||
|
||||
pub fn create_label ( label : & str ) -> Label {
|
||||
Label :: builder () . label (label) . build ()
|
||||
}
|
||||
|
||||
macro_rules ! create_horizontal_box { ( $ ( $ child : expr , ) * ) => { {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation ( gtk4 :: Orientation :: Horizontal )
|
||||
. build () ;
|
||||
$ ( container . append ( $ child ) ; ) *
|
||||
container
|
||||
} } }
|
||||
|
||||
macro_rules ! create_vertical_box { ( $ ( $ child : expr , ) * ) => { {
|
||||
let container = gtk4 :: Box :: builder ()
|
||||
. orientation ( gtk4 :: Orientation :: Vertical)
|
||||
. build () ;
|
||||
$ ( container . append ( $ child ) ; ) *
|
||||
container
|
||||
} } }
|
||||
|
||||
macro_rules ! create_view_stack { (
|
||||
$ ( $ title : expr , $ icon : expr , $ widget : expr , ) *
|
||||
) => { {
|
||||
let container = ViewStack :: new () ;
|
||||
$ ( container . add_titled_with_icon ( $ widget , None , $ title , $ icon ) ; ) *
|
||||
container
|
||||
} } }
|
||||
|
||||
pub fn create_vertical_filler_container ( child : & impl IsA <Widget> ) -> gtk4 :: Box {
|
||||
create_vertical_box ! (
|
||||
child ,
|
||||
& Bin :: builder ()
|
||||
. css_name ("filler")
|
||||
. vexpand (true)
|
||||
. build () ,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub (crate) use { create_horizontal_box , create_vertical_box , create_view_stack } ;
|
3
src/utility.rs
Normal file
3
src/utility.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub fn leak < 'l , T > ( inner : T ) -> & 'l mut T {
|
||||
Box :: leak ( Box :: new (inner) )
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue